From caf37786eb75044120fdf540857a1c77ab53bd65 Mon Sep 17 00:00:00 2001 From: Yunsup Sim Date: Tue, 10 Nov 2026 15:15:26 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(web):=20stage=EB=A5=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=83=89=EC=9D=B8=EC=97=90=EC=84=9C=20=EB=BA=80?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main이 아닌 Worker의 SSR 응답에 X-Robots-Tag: noindex, nofollow를 붙인다 - stage 빌드는 정적 자산용 _headers에 같은 헤더를 넣는다 - stage robots.txt는 크롤링을 허용하고 sitemap은 만들지 않는다 - stage 빌드 판별을 scripts/build-target.mjs로 모아 vite와 sitemap 생성이 함께 쓴다 - gtag 디버그 출력도 IS_STAGE로 판별한다 --- README.md | 4 +++- apps/web/scripts/build-target.mjs | 7 +++++++ apps/web/scripts/generate-sitemap.mjs | 19 +++++++++++++++---- apps/web/sitemap.config.mjs | 10 +++------- apps/web/src/lib/gtag.ts | 7 ++++--- apps/web/src/server.ts | 6 ++++++ apps/web/vite.config.ts | 4 +++- 7 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 apps/web/scripts/build-target.mjs diff --git a/README.md b/README.md index b326fcfe..1cba4161 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ pnpm build | `koin-api` | `apps/api` | `pnpm deploy` | `pnpm deploy:preview --name preview` | | `bcsd-error-automation` | `apps/error-automation` | `pnpm deploy` | 검사만 실행 | -웹은 `KOIN_API_ORIGIN`으로 Java API를 호출합니다. 운영은 `https://api.koreatech.in`, Preview와 로컬은 `https://api.stage.koreatech.in`입니다. 봇은 [별도 안내](apps/b-bot/README.md)를 따릅니다. +웹은 `KOIN_API_ORIGIN`으로 Java API를 호출합니다. 운영은 `https://api.koreatech.in`, Preview와 로컬은 `https://api.stage.koreatech.in`입니다. stage API가 발급하는 `__Secure-koin-stage-web-*` 쿠키 이름 같은 stage 전용 값은 빌드 상수 `NEXT_PUBLIC_KOIN_ENVIRONMENT`로 고릅니다. Workers Builds의 `KOIN_PREVIEW_BRANCH` 빌드와 로컬 개발 서버는 `preview`, 나머지 빌드는 `main`입니다. + +stage는 검색 색인에서 뺍니다. `KOIN_ENVIRONMENT`가 `main`이 아닌 Worker는 SSR 응답에, stage 빌드는 `_headers`로 정적 자산에 `X-Robots-Tag: noindex, nofollow`를 붙입니다. 크롤러가 이 헤더를 읽어야 기존 색인이 지워지므로 stage `robots.txt`는 크롤링을 허용하고 sitemap은 만들지 않습니다. 봇은 [별도 안내](apps/b-bot/README.md)를 따릅니다. ### 레거시 Worker API 비활성화와 복원 diff --git a/apps/web/scripts/build-target.mjs b/apps/web/scripts/build-target.mjs new file mode 100644 index 00000000..aa158781 --- /dev/null +++ b/apps/web/scripts/build-target.mjs @@ -0,0 +1,7 @@ +/** + * Workers Builds가 Preview 브랜치(stage.koreatech.in 배포)를 빌드하는지 판단한다. + * 운영 브랜치와 로컬 빌드는 운영 기준으로 빌드한다. + */ +export function isStageBuild(env) { + return env.WORKERS_CI === '1' && env.WORKERS_CI_BRANCH === (env.KOIN_PREVIEW_BRANCH || 'develop'); +} diff --git a/apps/web/scripts/generate-sitemap.mjs b/apps/web/scripts/generate-sitemap.mjs index 5b918f05..c0e32bfb 100644 --- a/apps/web/scripts/generate-sitemap.mjs +++ b/apps/web/scripts/generate-sitemap.mjs @@ -3,9 +3,22 @@ import path from 'node:path'; import ts from 'typescript'; import { loadEnv } from 'vite'; import { DEPARTMENT_CONTACT_CATEGORIES } from '../src/api/departmentContact/entity.ts'; +import config from '../sitemap.config.mjs'; +import { isStageBuild } from './build-target.mjs'; + +const target = 'dist/client'; +await mkdir(target, { recursive: true }); +if (isStageBuild(loadEnv('production', process.cwd(), ''))) { + // stage는 검색 색인에서 뺀다. 크롤러가 noindex를 읽어야 기존 색인이 지워지므로 robots.txt로 크롤링은 막지 않는다. + // _headers는 Worker를 거치지 않는 정적 자산에, server.ts는 SSR 응답에 같은 헤더를 붙인다. + await Promise.all([ + writeFile(path.join(target, 'robots.txt'), 'User-agent: *\nAllow: /\n'), + writeFile(path.join(target, '_headers'), '/*\n X-Robots-Tag: noindex, nofollow\n'), + ]); + process.stdout.write('Stage build: skipped sitemap and marked static assets noindex.\n'); + process.exit(0); +} -Object.assign(process.env, loadEnv('production', process.cwd(), 'NEXT_PUBLIC_')); -const { default: config } = await import('../sitemap.config.mjs'); const paths = new Set(DEPARTMENT_CONTACT_CATEGORIES.map((category) => `/department/${category}`)); const directory = path.resolve('src/routes'); for (const file of await readdir(directory)) { @@ -71,8 +84,6 @@ const robots = ].join('\n'), ) .join('\n\n') + `\n\nSitemap: ${siteUrl}/sitemap.xml\n`; -const target = 'dist/client'; -await mkdir(target, { recursive: true }); await Promise.all([ writeFile(path.join(target, 'sitemap.xml'), index), writeFile(path.join(target, 'sitemap-0.xml'), sitemap), diff --git a/apps/web/sitemap.config.mjs b/apps/web/sitemap.config.mjs index 27757bae..ab43ad6b 100644 --- a/apps/web/sitemap.config.mjs +++ b/apps/web/sitemap.config.mjs @@ -1,12 +1,8 @@ /** - * 공개 페이지 sitemap과 robots.txt 설정. - * 빌드 뒤 scripts/generate-sitemap.mjs가 정적 파일을 생성한다. - * stage/prod 환경 분기는 NEXT_PUBLIC_API_PATH 에 'stage' 포함 여부로 판단 (src/static/url.ts 와 동일 규칙). + * 운영 공개 페이지 sitemap과 robots.txt 설정. + * 빌드 뒤 scripts/generate-sitemap.mjs가 정적 파일을 생성한다. stage 빌드는 이 설정을 쓰지 않는다. */ -const IS_STAGE = process.env.NEXT_PUBLIC_API_PATH?.includes('stage'); -const SITE_URL = IS_STAGE ? 'https://stage.koreatech.in' : 'https://koreatech.in'; - const PRIVATE_PATHS = [ '/auth', '/auth/*', @@ -31,7 +27,7 @@ const PRIVATE_PATHS = [ ]; export default { - siteUrl: SITE_URL, + siteUrl: 'https://koreatech.in', generateRobotsTxt: true, sitemapSize: 5000, changefreq: 'daily', diff --git a/apps/web/src/lib/gtag.ts b/apps/web/src/lib/gtag.ts index 3952adcf..0a8ba3e9 100644 --- a/apps/web/src/lib/gtag.ts +++ b/apps/web/src/lib/gtag.ts @@ -1,3 +1,5 @@ +import { IS_STAGE } from 'static/url'; + export type LoggingTeam = 'CAMPUS' | 'BUSINESS' | 'USER'; /** 로깅 네이밍룰 v1.0의 GA 이벤트명. 서비스 담당자 기준으로 정해진다 */ @@ -45,7 +47,6 @@ interface SessionEvent { } export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID; -const API_PATH = process.env.NEXT_PUBLIC_API_PATH; // https://developers.google.com/analytics/devguides/collection/gtagjs/pages export const pageView = (url: string, userId?: string) => { @@ -80,7 +81,7 @@ export const event = (props: GTagEvent) => { screen_view: screenView, }); - if (API_PATH?.includes('stage')) { + if (IS_STAGE) { // eslint-disable-next-line no-console -- stage 환경에서만 분석 이벤트 디버깅용으로 출력 console.table({ '이벤트명(team/event_name)': eventName, @@ -106,7 +107,7 @@ export const startSession = ({ event_label, value, event_category, custom_sessio custom_session_id, }); - if (API_PATH?.includes('stage')) { + if (IS_STAGE) { // eslint-disable-next-line no-console -- stage 환경에서만 분석 이벤트 디버깅용으로 출력 console.table({ '세션 시작': event_label, diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts index 639b2528..f9a5c998 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -1,9 +1,14 @@ import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server'; import { createServerEntry } from '@tanstack/react-start/server-entry'; +import { env } from 'cloudflare:workers'; import { runWithServerRequestHeaders } from 'utils/ssr/cookieForwarding'; const handleRequest = createStartHandler(defaultStreamHandler); +const serverEnvironment: { readonly KOIN_ENVIRONMENT?: string } = env; +// stage.koreatech.in과 Preview URL은 운영 페이지와 내용이 겹치므로 검색 색인에서 뺀다. +// robots.txt로 막으면 크롤러가 이 헤더를 읽지 못해 이미 색인된 페이지가 남으므로 크롤링은 허용한다. +const isIndexable = serverEnvironment.KOIN_ENVIRONMENT === 'main'; const entry = createServerEntry({ async fetch(request) { const response = await runWithServerRequestHeaders( @@ -13,6 +18,7 @@ const entry = createServerEntry({ const headers = new Headers(response.headers); headers.set('Document-Policy', 'js-profiling'); if (!headers.has('Cache-Control')) headers.set('Cache-Control', 'private, no-store'); + if (!isIndexable) headers.set('X-Robots-Tag', 'noindex, nofollow'); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); }, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 159ec9fb..b196ae24 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,8 @@ import react from '@vitejs/plugin-react'; import { defineConfig, loadEnv, type Plugin } from 'vite'; import svgr from 'vite-plugin-svgr'; +import { isStageBuild } from './scripts/build-target.mjs'; + const root = fileURLToPath(new URL('.', import.meta.url)); const publicEnvironmentKeys = [ 'NEXT_PUBLIC_API_PATH', @@ -77,7 +79,7 @@ export default defineConfig(({ command, mode, isPreview }) => { const offlineSmoke = env.AUTOFIX_OFFLINE_SMOKE === '1' && env.WORKERS_CI !== '1'; const devServer = command === 'serve' && !isPreview; // 클라이언트와 SSR의 쿠키 이름은 API 프록시 경로가 아닌 배포 환경으로 결정한다. - env.NEXT_PUBLIC_KOIN_ENVIRONMENT = !offlineSmoke && (devServer || branch === previewBranch) ? 'preview' : 'main'; + env.NEXT_PUBLIC_KOIN_ENVIRONMENT = !offlineSmoke && (devServer || isStageBuild(env)) ? 'preview' : 'main'; env.NEXT_PUBLIC_SENTRY_ENVIRONMENT = branch === productionBranch ? 'production' : 'preview'; env.NEXT_PUBLIC_SENTRY_RELEASE ||= env.WORKERS_CI_COMMIT_SHA; From 6f1cc3d6ce422d9e21f244f6b5615a80f916f503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunsup=20Sim=28=EC=8B=AC=EC=9C=A4=EC=84=AD=29?= Date: Tue, 10 Nov 2026 21:25:44 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(web):=20=EC=A3=BC=EB=B3=80=EC=83=81?= =?UTF-8?q?=EC=A0=90=20=EC=A0=95=EB=A0=AC=C2=B7=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=9D=84=20=EC=A6=89=EC=8B=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=ED=95=98=EA=B3=A0=20=EB=AA=A9=EB=A1=9D=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=EC=9D=84=20=EB=B3=B4=EC=9D=B8?= =?UTF-8?q?=EB=8B=A4=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): 주변상점 정렬·필터 변경 시 목록 스켈레톤을 보인다 * fix(web): 주변상점 정렬·필터 변경이 서버 왕복을 기다리지 않게 한다 * fix: PR #69 자동 리뷰 수정 Reviewed-Head: 42a92aaca06f05ca4af2d07b0b27439c2e5e1781 Base: caf37786eb75044120fdf540857a1c77ab53bd65 * fix(web): 서버 스냅샷 재조회와 상점 필터 즉시 반영 * fix(web): 상점 목록 로딩 안내와 주석을 수정한다 --------- Co-authored-by: bcsd-united-error-publisher[bot] <349694229+bcsd-united-error-publisher[bot]@users.noreply.github.com> --- .../StoreListPage/StoreListPage.module.scss | 12 ++++ .../ShopListSkeleton.module.scss | 70 +++++++++++++++++++ .../components/ShopListSkeleton/index.tsx | 16 +++++ .../Store/mobile/StoreListPage/index.tsx | 34 +++++---- apps/web/src/routes/store.index.tsx | 5 +- 5 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/ShopListSkeleton.module.scss create mode 100644 apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/index.tsx diff --git a/apps/web/src/components/Store/mobile/StoreListPage/StoreListPage.module.scss b/apps/web/src/components/Store/mobile/StoreListPage/StoreListPage.module.scss index 9d8f71bd..e264b673 100644 --- a/apps/web/src/components/Store/mobile/StoreListPage/StoreListPage.module.scss +++ b/apps/web/src/components/Store/mobile/StoreListPage/StoreListPage.module.scss @@ -223,6 +223,18 @@ } } +.loading-status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; +} + .list { box-sizing: border-box; display: grid; diff --git a/apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/ShopListSkeleton.module.scss b/apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/ShopListSkeleton.module.scss new file mode 100644 index 00000000..d7542b70 --- /dev/null +++ b/apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/ShopListSkeleton.module.scss @@ -0,0 +1,70 @@ +@use "src/utils/scss/order-palette" as palette; + +@keyframes shimmer { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +@mixin bone { + border-radius: 4px; + background-color: palette.$neutral-200; + animation: shimmer 1.5s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +// ShopCard(.card)의 크기·테두리를 따른다 +.card { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 20px; + overflow: hidden; + border: 0.5px solid palette.$neutral-200; + border-radius: 8px; + background-color: #fff; + + &__thumbnail { + @include bone; + + flex-shrink: 0; + width: 128px; + height: 128px; + border-radius: 8px; + } + + &__info { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + padding-right: 64px; + } + + &__name { + @include bone; + + width: 100%; + max-width: 120px; + height: 20px; + } + + &__rating { + @include bone; + + width: 100%; + max-width: 96px; + height: 16px; + } +} diff --git a/apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/index.tsx b/apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/index.tsx new file mode 100644 index 00000000..57ad46c0 --- /dev/null +++ b/apps/web/src/components/Store/mobile/StoreListPage/components/ShopListSkeleton/index.tsx @@ -0,0 +1,16 @@ +import styles from './ShopListSkeleton.module.scss'; + +const SKELETON_CARD_COUNT = 5; + +// 정렬·필터를 바꿔 새 목록을 받는 동안 ShopCard 자리를 채운다. 로딩 안내는 목록의 상태 메시지에서 제공한다 +export default function ShopListSkeleton() { + return Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => ( +
+
+
+
+
+
+
+ )); +} diff --git a/apps/web/src/components/Store/mobile/StoreListPage/index.tsx b/apps/web/src/components/Store/mobile/StoreListPage/index.tsx index af44c76b..aeaa8e6f 100644 --- a/apps/web/src/components/Store/mobile/StoreListPage/index.tsx +++ b/apps/web/src/components/Store/mobile/StoreListPage/index.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect } from 'react'; +import { startTransition, useCallback, useEffect, useOptimistic } from 'react'; import { cn } from '@bcsdlab/utils'; -import { keepPreviousData, useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useRouter, useSearch } from '@tanstack/react-router'; import type { ShopCategory } from 'api/storeMobile/entity'; @@ -23,6 +23,7 @@ import useLogger from 'utils/hooks/analytics/useLogger'; import useBooleanState from 'utils/hooks/state/useBooleanState'; import SearchBar from './components/SearchBar'; +import ShopListSkeleton from './components/ShopListSkeleton'; import useScrollLogging from './hooks/useScrollLogging'; import { getListRequestParams, @@ -39,17 +40,16 @@ import styles from './StoreListPage.module.scss'; export default function StoreListPage() { const router = useRouter(); const routeSearch = useSearch({ strict: false }); + const [optimisticSearch, setOptimisticSearch] = useOptimistic(routeSearch); const logger = useLogger(); const [isSortModalOpen, openSortModal, closeSortModal] = useBooleanState(false); - const { category: selectedCategory, sort: selectedSort, filter: selectedFilter } = parseListQuery(routeSearch); + const { category: selectedCategory, sort: selectedSort, filter: selectedFilter } = parseListQuery(optimisticSearch); const { sorter, filter } = getListRequestParams(selectedSort, selectedFilter); const { data: categories } = useSuspenseQuery(storeMobileQueries.categories()); - const { data: shopList } = useQuery({ - ...storeMobileQueries.list(sorter, filter), - placeholderData: keepPreviousData, - }); + // 정렬·필터마다 요청이 다르다. 이전 조건의 목록을 남기지 않고 새 조건의 첫 응답 전까지 스켈레톤을 보인다 + const { data: shopList, isPending: isShopListPending } = useQuery(storeMobileQueries.list(sorter, filter)); const shops = (shopList?.shops ?? []).filter((shop) => { if (!selectedCategory || selectedCategory === 1) return true; @@ -61,11 +61,14 @@ export default function StoreListPage() { categories.shop_categories.find((category) => category.id === selectedCategory)?.name || '전체보기'; const replaceQuery = (key: 'category' | 'sort' | 'filter', value: string | null) => { - const nextQuery = { ...routeSearch }; + const nextQuery = { ...optimisticSearch }; if (value === null) delete nextQuery[key]; else nextQuery[key] = value; - router.navigate({ to: ROUTES.Store(), search: nextQuery, replace: true, resetScroll: false }); + startTransition(async () => { + setOptimisticSearch(nextQuery); + await router.navigate({ to: ROUTES.Store(), search: nextQuery, replace: true, resetScroll: false }); + }); }; const currentSortLabel = SORT_OPTIONS.find((option) => option.id === selectedSort)?.label || '기본순'; @@ -205,8 +208,13 @@ export default function StoreListPage() {
-
- {shops.length > 0 ? ( +

+ {isShopListPending ? '상점 목록을 불러오는 중입니다.' : ''} +

+ +
+ {isShopListPending && } + {!isShopListPending && shops.map((shop) => ( - )) - ) : ( + ))} + {!isShopListPending && shops.length === 0 && (
이용 가능한 가게가 없어요
diff --git a/apps/web/src/routes/store.index.tsx b/apps/web/src/routes/store.index.tsx index 93b1ea80..13a40def 100644 --- a/apps/web/src/routes/store.index.tsx +++ b/apps/web/src/routes/store.index.tsx @@ -425,10 +425,11 @@ const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) }), ); +// 정렬·필터·카테고리·검색어는 화면이 클라이언트 쿼리로 처리한다. search만 바뀌면 페이지 loader는 다시 실행하지 않는다. +// StoreListPage는 낙관적 상태로 선택을 즉시 반영한다. export const Route = createFileRoute('/store/')({ validateSearch: (search: Record) => search as Record, - loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, location }) => getPageData({ data: { params, search: location.search } }), component: RouteComponent, }); From 5ac3fa8c1d13196ee9a3b0e136cc5be183a14109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunsup=20Sim=28=EC=8B=AC=EC=9C=A4=EC=84=AD=29?= Date: Tue, 10 Nov 2026 22:13:20 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(web):=20=ED=95=98=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9D=B4=ED=9B=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9D=B4=EB=8F=99=EC=9D=84=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=97=86=EC=9D=B4=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20loader=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): 하이드레이션 이후 화면 이동을 서버 함수 없이 브라우저 loader로 처리한다 * fix(web): 화면 이동의 세션과 서버 시각을 정확히 반영한다 * fix(web): 공유 캐시와 세션 스냅샷을 바로잡는다 익명 HTML 공개 캐시를 복원하고 공유 캐시 문서의 서버 시계를 새 세션 응답으로 보정한다. CSRF 쿠키에 세션 확인을 연결하고 계정 변경 시 사용자 캐시와 쿠키 변경 경합을 처리한다. 식단과 학기 날짜를 한국 시간 및 서버 스냅샷으로 맞추고 운영 계약을 갱신한다. * fix(web): 세션과 서버 시각 복구를 바로잡는다 세션 API의 타임아웃과 no-store 응답을 사용하고 시각 표본 실패 시 현재 이동에만 SSR 오프셋을 사용한다. 확인된 세션 캐시를 네트워크 오류에서 유지하고 Date 표본의 정밀도와 초기화 순서를 보완한다. 페이지 로드 시각 계약을 명확히 하고 시각 및 세션 회귀 검사를 추가한다. * fix(web): 서버 시계 표본 조회 지연을 줄인다 --- CONTRIBUTING.md | 3 +- README.md | 2 + apps/web/eslint.config.mjs | 4 + apps/web/package.json | 3 +- .../IndexMobileCafeteria/index.tsx | 9 +- .../SemesterList/AddSemesterModal/index.tsx | 15 +- .../components/app/ApplicationProviders.tsx | 13 + .../components/WeeklyDatePicker/index.tsx | 25 +- .../components/DateNavigator/index.tsx | 13 +- .../cafeteria/PCCafeteriaPage/index.tsx | 10 +- .../cafeteria/hooks/useCafeteriaLiveNow.ts | 3 +- .../cafeteria/hooks/useCafeteriaParams.ts | 23 +- .../src/components/cafeteria/utils/time.ts | 48 +-- apps/web/src/routes/__root.tsx | 12 +- apps/web/src/routes/articles.$id.index.tsx | 88 +++--- apps/web/src/routes/articles.index.tsx | 50 ++-- apps/web/src/routes/benefitstore.index.tsx | 74 ++--- apps/web/src/routes/bus.route.index.tsx | 23 +- apps/web/src/routes/bus.shuttle.index.tsx | 44 ++- apps/web/src/routes/cafeteria.index.tsx | 64 ++-- .../callvan.$postId.participants.index.tsx | 47 ++- .../callvan.$postId.report.$userId.index.tsx | 34 ++- apps/web/src/routes/callvan.index.tsx | 64 ++-- .../routes/callvan.notifications.index.tsx | 53 ++-- apps/web/src/routes/clubs.$id.index.tsx | 84 +++--- apps/web/src/routes/clubs.index.tsx | 61 ++-- .../src/routes/department.$category.index.tsx | 20 +- apps/web/src/routes/index.tsx | 276 +++++++++--------- apps/web/src/routes/lost-item.$id.index.tsx | 55 ++-- apps/web/src/routes/lost-item.index.tsx | 62 ++-- .../src/routes/lost-item.report.$id.index.tsx | 32 +- .../routes/order.cancel.$paymentId.index.tsx | 20 +- apps/web/src/routes/order.cart.index.tsx | 45 ++- apps/web/src/routes/order.checkout.index.tsx | 73 +++-- .../routes/order.delivery.campus.index.tsx | 39 ++- .../order.delivery.outside.detail.index.tsx | 26 +- .../routes/order.delivery.outside.index.tsx | 14 +- apps/web/src/routes/order.index.tsx | 43 ++- apps/web/src/routes/order.orders.index.tsx | 43 ++- .../routes/order.result.$paymentId.index.tsx | 48 +-- apps/web/src/routes/order.shops.$id.index.tsx | 90 +++--- .../src/routes/order.shops.$id.info.index.tsx | 48 +-- .../order.shops.$id.menus.$menuId.index.tsx | 60 ++-- .../routes/order.shops.$id.reviews.index.tsx | 75 +++-- apps/web/src/routes/profile.index.tsx | 171 ++++++----- ...ew.shopid.$id.reviewid.$reviewid.index.tsx | 38 +-- apps/web/src/routes/room.$id.index.tsx | 53 ++-- apps/web/src/routes/room.index.tsx | 26 +- .../web/src/routes/store.$id.events.index.tsx | 50 ++-- apps/web/src/routes/store.$id.index.tsx | 149 +++++----- apps/web/src/routes/store.$id.info.index.tsx | 50 ++-- .../src/routes/store.$id.reviews.index.tsx | 68 ++--- apps/web/src/routes/store.index.tsx | 111 +++---- .../web/src/routes/store.review.$id.index.tsx | 48 +-- .../store.review.edit.$id.$reviewid.index.tsx | 55 ++-- apps/web/src/routes/store.search.index.tsx | 30 +- apps/web/src/routes/team.index.tsx | 34 +-- .../src/routes/team.profile.edit.index.tsx | 31 +- apps/web/src/routes/team.profile.index.tsx | 37 ++- apps/web/src/routes/timetable.index.tsx | 72 +++-- apps/web/src/routes/timetable.list.index.tsx | 76 +++-- .../web/src/routes/timetable.modify.index.tsx | 67 ++--- apps/web/src/utils/auth/fetchSession.ts | 9 +- .../web/src/utils/auth/sessionConfirmation.ts | 37 +++ apps/web/src/utils/ssr/pageData.ts | 38 +-- apps/web/src/utils/ssr/pageLoader.ts | 126 ++++++++ apps/web/src/utils/ssr/requestContext.ts | 15 +- apps/web/src/utils/ssr/serverClock.ts | 39 +++ apps/web/src/utils/ssr/useServerNow.ts | 6 +- apps/web/src/utils/timetable/semester.ts | 8 +- apps/web/src/utils/ts/apiClient.ts | 3 + apps/web/src/utils/ts/calendar.ts | 24 +- .../test/page-loader-clock-timeout.test.ts | 41 +++ apps/web/test/page-loader-offline.test.ts | 56 ++++ apps/web/test/page-loader-session.test.ts | 55 ++++ apps/web/test/server-clock-ssr.test.ts | 21 ++ apps/web/test/server-clock.test.ts | 26 ++ apps/web/tsconfig.json | 2 +- 78 files changed, 1946 insertions(+), 1664 deletions(-) create mode 100644 apps/web/src/utils/auth/sessionConfirmation.ts create mode 100644 apps/web/src/utils/ssr/pageLoader.ts create mode 100644 apps/web/src/utils/ssr/serverClock.ts create mode 100644 apps/web/test/page-loader-clock-timeout.test.ts create mode 100644 apps/web/test/page-loader-offline.test.ts create mode 100644 apps/web/test/page-loader-session.test.ts create mode 100644 apps/web/test/server-clock-ssr.test.ts create mode 100644 apps/web/test/server-clock.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61c112bf..1f352411 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,8 @@ pnpm dev - 웹은 기존 절대 경로, API는 `@api/*`를 사용합니다. - 내부 화면 이동은 TanStack Router와 `ROUTES`를 사용합니다. - 웹 mutation 오류는 `interfaces/APIError`의 `isKoinError()`로 좁힌 뒤 `showToast()`로 표시합니다. 업무 오류가 아닌 HTTP·네트워크 오류는 같은 모듈의 `HttpError`를 사용하며, 상태 코드는 `utils/ts/httpError`의 `getErrorStatus()`로 읽습니다. -- 서버가 아는 기기·인증·시각은 서버 스냅샷으로 전달합니다. 이를 클라이언트 시계나 영속 회원 유형으로 대체하지 않습니다. +- 페이지 로드 스냅샷의 기기·인증·시각은 SSR에서 전달합니다. 하이드레이션 이후 `utils/ssr/pageLoader`는 기기는 SSR 값, 인증은 CSRF 쿠키에 결부된 세션 쿼리, 시각은 서버 오프셋을 사용합니다. 오프셋은 세션 응답의 Date로 갱신하며, 공유 캐시 문서에서 표본을 얻지 못하면 해당 이동에만 SSR 시각 기준 오프셋을 사용하고 다음 이동에서 다시 시도합니다. 실시간 시계 등 다른 클라이언트 시각에는 이 계약이 적용되지 않습니다. +- 페이지 loader는 `definePageLoad()`로 정의해 서버 함수(`loadPageData`)와 `createPageLoader()`에 같이 넘깁니다. load는 SSR과 브라우저 이동 모두에서 실행되므로 서버 전용 API를 쓰지 않고, 쿠키는 `context.getCookie`, 쿼리는 `context.queryClient`, 시각은 `serverRequest.now`를 사용합니다. - 요청별 QueryClient·쿠키 전달 컨텍스트를 공유 전역 상태로 바꾸지 않습니다. - 웹 HTTP 요청은 `APIClient`의 native `fetch` 경로를 사용합니다. 브라우저는 same-origin `/api`, SSR은 검증된 Worker 대상과 요청별 Cookie/Origin을 사용합니다. 타임아웃은 응답 본문 수신까지 적용하며, 브라우저 401은 세션을 한 번 갱신한 뒤 최신 CSRF 헤더로 한 번만 재시도합니다. 직접 파일 업로드는 원본 Blob을 보내고 서비스 쿠키·CSRF를 제외합니다. - D1 변경은 Drizzle의 파라미터화 SQL·원자 배치를 사용합니다. DO 직렬화만으로 DB 원자성이 생기는 것은 아닙니다. diff --git a/README.md b/README.md index 1cba4161..d93c0e92 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ pnpm build stage는 검색 색인에서 뺍니다. `KOIN_ENVIRONMENT`가 `main`이 아닌 Worker는 SSR 응답에, stage 빌드는 `_headers`로 정적 자산에 `X-Robots-Tag: noindex, nofollow`를 붙입니다. 크롤러가 이 헤더를 읽어야 기존 색인이 지워지므로 stage `robots.txt`는 크롤링을 허용하고 sitemap은 만들지 않습니다. 봇은 [별도 안내](apps/b-bot/README.md)를 따릅니다. +익명 SSR 페이지의 공개 캐시 정책은 nginx 공유 캐시에 적용됩니다. 공유 캐시된 HTML의 렌더 시각은 오래될 수 있으며, 브라우저 화면 이동은 캐시되지 않은 세션 응답의 서버 시각으로 기준을 맞춥니다. + ### 레거시 Worker API 비활성화와 복원 `koin-api` Worker는 `apps/api/wrangler.jsonc`의 `API_ENABLED`가 `"false"`라 모든 요청에 `503 API_DISABLED`로 응답합니다. 코드와 D1·R2·Queue·DO 리소스는 삭제하지 않습니다. diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index b4edafaf..94353c91 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -40,6 +40,10 @@ export default [ jsxA11y.flatConfigs.recommended, ...tseslint.configs.recommended, ...tanstackQuery.configs['flat/recommended'], + { + files: ['test/**/*.{ts,tsx}'], + settings: { 'import/resolver': { typescript: {} } }, + }, { files: ['src/**/*.{ts,tsx,js,jsx}'], diff --git a/apps/web/package.json b/apps/web/package.json index 7231e781..6739286a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,10 +8,11 @@ "deploy": "wrangler deploy", "deploy:preview": "wrangler preview --name stage", "cf:typegen": "wrangler types", + "test": "node --import tsx --test test/*.test.ts", "typecheck": "tsc --noEmit", "build": "vite build && node scripts/generate-sitemap.mjs", "lint": "pnpm lint:eslint && pnpm lint:stylelint", - "lint:eslint": "eslint src/ --max-warnings 0", + "lint:eslint": "eslint src/ test/ --max-warnings 0", "lint:stylelint": "stylelint \"src/**/*.scss\" --config .stylelint.json", "dev": "vite --port 3000" }, diff --git a/apps/web/src/components/IndexComponents/IndexMobileCafeteria/index.tsx b/apps/web/src/components/IndexComponents/IndexMobileCafeteria/index.tsx index afba2c20..556c0bbc 100644 --- a/apps/web/src/components/IndexComponents/IndexMobileCafeteria/index.tsx +++ b/apps/web/src/components/IndexComponents/IndexMobileCafeteria/index.tsx @@ -12,7 +12,8 @@ import { DINING_TYPE_MAP } from 'static/cafeteria'; import ROUTES from 'static/routes'; import useLogger from 'utils/hooks/analytics/useLogger'; import { parseSearch } from 'utils/routing/search'; -import { getYyyyMmDd } from 'utils/ts/calendar'; +import { useServerNow } from 'utils/ssr/useServerNow'; +import { kstDateKey, kstDayOfWeek } from 'utils/ts/calendar'; import styles from './IndexMobileCafeteria.module.scss'; @@ -20,7 +21,7 @@ const mealTabs: DiningPlace[] = ['A코너', 'B코너', 'C코너', '능수관']; const getDiningTimeLabel = (cafeteriaInfo: CoopShopDetailResponse, diningDate: Date, diningType: Dining['type']) => { const diningTypeLabel = DINING_TYPE_MAP[diningType]; - const dayOfWeek = diningDate.getDay(); + const dayOfWeek = kstDayOfWeek(diningDate); const dayLabel = dayOfWeek === 6 ? '토요일' : '평일'; const schedule = cafeteriaInfo.opens.find((open) => open.day_of_week === dayLabel && open.type === diningTypeLabel); @@ -61,7 +62,7 @@ function MealCard({ dining, diningLabel, href, onClick }: MealCardProps) { function IndexMobileCafeteria() { const logger = useLogger(); - const diningTime = new DiningTime(); + const diningTime = new DiningTime(useServerNow()); const diningDate = diningTime.generateDiningDate(); const currentDiningType: DiningType = diningTime.getType(); @@ -78,7 +79,7 @@ function IndexMobileCafeteria() { const diningLabel = cafeteriaInfo ? getDiningTimeLabel(cafeteriaInfo, diningDate, currentDiningType) : '운영시간 정보 제공 불가'; - const cafeteriaLink = ROUTES.Cafeteria({ date: getYyyyMmDd(diningDate), type: currentDiningType }); + const cafeteriaLink = ROUTES.Cafeteria({ date: kstDateKey(diningDate), type: currentDiningType }); const activePlace = availableDinings.some((dining) => dining.place === selectedPlace) ? selectedPlace : availableDinings[0]?.place; diff --git a/apps/web/src/components/TimetablePage/components/SemesterList/AddSemesterModal/index.tsx b/apps/web/src/components/TimetablePage/components/SemesterList/AddSemesterModal/index.tsx index 0b5a863d..c86dfca2 100644 --- a/apps/web/src/components/TimetablePage/components/SemesterList/AddSemesterModal/index.tsx +++ b/apps/web/src/components/TimetablePage/components/SemesterList/AddSemesterModal/index.tsx @@ -12,6 +12,7 @@ import CloseIcon from 'assets/svg/close-icon-black.svg'; import { Selector } from 'components/ui/Selector'; import useLogger from 'utils/hooks/analytics/useLogger'; import { useOutsideClick } from 'utils/hooks/ui/useOutsideClick'; +import { useServerCalendarDate } from 'utils/ssr/useServerNow'; import showToast from 'utils/ts/showToast'; import styles from './AddSemesterModal.module.scss'; @@ -23,14 +24,6 @@ export interface AddSemesterModalProps { mySemester: SemesterCheckResponse | null; } -const currentYear = new Date().getFullYear(); -const startYear = 2019; -const years = Array.from({ length: currentYear - startYear + 1 }, (_, index) => { - const year = currentYear - index; - - return { label: `${year}년도`, value: `${year}년도` }; -}); - const semester = [ { label: '겨울학기', value: '겨울학기' }, { label: '2학기', value: '2학기' }, @@ -45,6 +38,12 @@ export default function AddSemesterModal({ mySemester, }: AddSemesterModalProps) { const logger = useLogger(); + const currentYear = useServerCalendarDate().getFullYear(); + const years = Array.from({ length: currentYear - 2019 + 1 }, (_, index) => { + const year = currentYear - index; + + return { label: `${year}년도`, value: `${year}년도` }; + }); const { backgroundRef } = useOutsideClick({ onOutsideClick: onClose }); const [yearValue, setYearValue] = React.useState(years[0].label); diff --git a/apps/web/src/components/app/ApplicationProviders.tsx b/apps/web/src/components/app/ApplicationProviders.tsx index 192190e4..7898ba66 100644 --- a/apps/web/src/components/app/ApplicationProviders.tsx +++ b/apps/web/src/components/app/ApplicationProviders.tsx @@ -7,8 +7,10 @@ import PortalProvider from 'components/modal/Modal/PortalProvider'; import Seo from 'components/seo/Seo'; import ROUTES from 'static/routes'; import { clearLegacySessionStorage } from 'utils/auth/legacyStorage'; +import { initializeSessionConfirmation } from 'utils/auth/sessionConfirmation'; import { useSessionState } from 'utils/hooks/auth/useSession'; import type { ServerRequestContext } from 'utils/ssr/requestContext'; +import { initializeServerClock } from 'utils/ssr/serverClock'; import { ServerRequestProvider } from 'utils/ssr/useServerRequest'; import { isomorphicLocalStorage } from 'utils/ts/env'; import { createQueryClient, queryClient } from 'utils/ts/queryClient'; @@ -48,6 +50,17 @@ export default function ApplicationProviders({ const title = leaf?.staticData.title; const pageTitle = typeof title === 'function' ? title(pathname) : title; + // 자식의 useSessionState와 첫 클라이언트 이동이 스냅샷을 읽기 전에 동기 초기화해야 한다. + useState(() => { + if (typeof window !== 'undefined') { + const initial = data?.serverRequest ?? serverRequest; + if (initial) { + initializeSessionConfirmation(initial.isLoggedIn); + initializeServerClock(initial.now, initial.mayBeSharedCached); + } + } + }); + useEffect(() => { clearLegacySessionStorage(); if (typeof window === 'undefined') return; diff --git a/apps/web/src/components/cafeteria/MobileCafeteriaPage/components/WeeklyDatePicker/index.tsx b/apps/web/src/components/cafeteria/MobileCafeteriaPage/components/WeeklyDatePicker/index.tsx index 8e8409c2..52ea424f 100644 --- a/apps/web/src/components/cafeteria/MobileCafeteriaPage/components/WeeklyDatePicker/index.tsx +++ b/apps/web/src/components/cafeteria/MobileCafeteriaPage/components/WeeklyDatePicker/index.tsx @@ -2,6 +2,7 @@ import { cn } from '@bcsdlab/utils'; import { useCafeteriaLiveNow } from 'components/cafeteria/hooks/useCafeteriaLiveNow'; import { useCafeteriaParams } from 'components/cafeteria/hooks/useCafeteriaParams'; +import { kstDateKey, kstDayOfWeek } from 'utils/ts/calendar'; import styles from './WeeklyDatePicker.module.scss'; @@ -11,22 +12,8 @@ export default function WeeklyDatePicker() { const { date } = useCafeteriaParams(); const today = useCafeteriaLiveNow(); - const addDays = (date: Date, days: number) => { - const result = new Date(date); - result.setDate(result.getDate() + days); - - return result; - }; - - const thisWeek = Array.from({ length: 7 }).map((_, index) => addDays(today, index - 3)); - - const dateFormat = (d = today) => { - const year = d.getFullYear(); - const month = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - - return `${year}-${month}-${day}`; - }; + const thisWeek = Array.from({ length: 7 }, (_, index) => new Date(today.getTime() + (index - 3) * 86400000)); + const dateFormat = (d = today) => kstDateKey(d); return (
@@ -38,9 +25,9 @@ export default function WeeklyDatePicker() { [styles.picker__date]: true, [styles['picker__date--before']]: dateFormat(day) < dateFormat(), })} - key={day.toDateString()} + key={dateFormat(day)} > - {WEEK[day.getDay()]} + {WEEK[kstDayOfWeek(day)]}
))} diff --git a/apps/web/src/components/cafeteria/PCCafeteriaPage/components/DateNavigator/index.tsx b/apps/web/src/components/cafeteria/PCCafeteriaPage/components/DateNavigator/index.tsx index 39cb40a2..98c246a0 100644 --- a/apps/web/src/components/cafeteria/PCCafeteriaPage/components/DateNavigator/index.tsx +++ b/apps/web/src/components/cafeteria/PCCafeteriaPage/components/DateNavigator/index.tsx @@ -4,6 +4,7 @@ import LeftArrow from 'assets/svg/left-angle-bracket.svg'; import RightArrow from 'assets/svg/right-angle-bracket.svg'; import CafeteriaInfoBoundary from 'components/cafeteria/components/CafeteriaInfoBoundary'; import { useCafeteriaParams } from 'components/cafeteria/hooks/useCafeteriaParams'; +import { kstDateKey, kstDayOfWeek } from 'utils/ts/calendar'; import CafeteriaInfoTrigger from './components/CafeteriaInfoTrigger'; import styles from './DateNavigator.module.scss'; @@ -17,22 +18,20 @@ interface DayInfo { const generateWeek = (today: Date) => { const WEEK = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; - const currentDate = new Date(today); - while (currentDate.getDay() !== 0) { - currentDate.setDate(currentDate.getDate() - 1); - } + const currentDate = new Date(`${kstDateKey(today)}T00:00:00Z`); + currentDate.setUTCDate(currentDate.getUTCDate() - kstDayOfWeek(currentDate)); const week: DayInfo[] = []; for (let i = 0; i < 7; i += 1) { const weekDay = WEEK[i]; - const dateOfMonth = currentDate.getDate(); + const dateOfMonth = currentDate.getUTCDate(); const newDate = new Date(currentDate); week.push({ weekDay, dateOfMonth, date: newDate, }); - currentDate.setDate(currentDate.getDate() + 1); + currentDate.setUTCDate(currentDate.getUTCDate() + 1); } return week; @@ -76,7 +75,7 @@ export default function DateNavigator() { key={dayInfo.weekDay} className={cn({ [styles['week__one-day']]: true, - [styles['week__one-day--selected']]: dayInfo.date.toDateString() === date.current().toDateString(), + [styles['week__one-day--selected']]: kstDateKey(dayInfo.date) === kstDateKey(date.current()), })} type="button" onClick={() => date.set(dayInfo.date)} diff --git a/apps/web/src/components/cafeteria/PCCafeteriaPage/index.tsx b/apps/web/src/components/cafeteria/PCCafeteriaPage/index.tsx index 934eba40..75d01aeb 100644 --- a/apps/web/src/components/cafeteria/PCCafeteriaPage/index.tsx +++ b/apps/web/src/components/cafeteria/PCCafeteriaPage/index.tsx @@ -16,17 +16,15 @@ import useBooleanState from 'utils/hooks/state/useBooleanState'; import { useEscapeKeyDown } from 'utils/hooks/ui/useEscapeKeyDown'; import { useOutsideClick } from 'utils/hooks/ui/useOutsideClick'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; +import { kstDateKey, kstDayOfWeek } from 'utils/ts/calendar'; import DateNavigator from './components/DateNavigator'; import PCDiningBlocks from './components/PCDiningBlocks'; import styles from './PCCafeteriaPage.module.scss'; const getWeekAgo = (now: Date) => { - const twoWeeksAgoSunday = new Date(now); - while (twoWeeksAgoSunday.getDay() !== 0) { - twoWeeksAgoSunday.setDate(twoWeeksAgoSunday.getDate() - 1); - } - twoWeeksAgoSunday.setDate(twoWeeksAgoSunday.getDate() - 8); + const twoWeeksAgoSunday = new Date(`${kstDateKey(now)}T00:00:00Z`); + twoWeeksAgoSunday.setUTCDate(twoWeeksAgoSunday.getUTCDate() - kstDayOfWeek(twoWeeksAgoSunday) - 8); return twoWeeksAgoSunday; }; @@ -73,7 +71,7 @@ function PCCafeteriaComponent() { return '내일'; } - return DAYS[date.current().getDay()]; + return DAYS[kstDayOfWeek(date.current())]; }; useScrollToTop(); diff --git a/apps/web/src/components/cafeteria/hooks/useCafeteriaLiveNow.ts b/apps/web/src/components/cafeteria/hooks/useCafeteriaLiveNow.ts index 89834e59..b7104086 100644 --- a/apps/web/src/components/cafeteria/hooks/useCafeteriaLiveNow.ts +++ b/apps/web/src/components/cafeteria/hooks/useCafeteriaLiveNow.ts @@ -1,7 +1,8 @@ import { useCafeteriaServerValue } from 'components/cafeteria/context/CafeteriaServerContext'; -import { DiningTime, kstDateKey } from 'components/cafeteria/utils/time'; +import { DiningTime } from 'components/cafeteria/utils/time'; import useLiveNow from 'utils/hooks/state/useLiveNow'; import { useServerNow } from 'utils/ssr/useServerNow'; +import { kstDateKey } from 'utils/ts/calendar'; const POLL_MS = 60_000; diff --git a/apps/web/src/components/cafeteria/hooks/useCafeteriaParams.ts b/apps/web/src/components/cafeteria/hooks/useCafeteriaParams.ts index 0b97d913..11e09050 100644 --- a/apps/web/src/components/cafeteria/hooks/useCafeteriaParams.ts +++ b/apps/web/src/components/cafeteria/hooks/useCafeteriaParams.ts @@ -4,6 +4,7 @@ import type { DiningType } from 'api/dinings/entity'; import { useCafeteriaLiveNow } from 'components/cafeteria/hooks/useCafeteriaLiveNow'; import { DiningTime } from 'components/cafeteria/utils/time'; import { DINING_TYPES } from 'static/cafeteria'; +import { kstDateKey } from 'utils/ts/calendar'; const DATE_KEY = 'date'; const TYPE_KEY = 'type'; @@ -14,8 +15,7 @@ export const useCafeteriaParams = () => { const query = useSearch({ strict: false }); const renderToday = useCafeteriaLiveNow(); - const renderTomorrow = new Date(renderToday); - renderTomorrow.setDate(renderToday.getDate() + 1); + const renderTomorrow = new Date(renderToday.getTime() + 24 * 60 * 60 * 1000); const dateParam = (): Date | null => { const value = query[DATE_KEY]; @@ -33,27 +33,24 @@ export const useCafeteriaParams = () => { }; const shiftDate = (days: number) => { - const base = dateParam() ?? new Date(renderToday); - base.setDate(base.getDate() + days); - updateDateQuery(base.toISOString().slice(0, 10)); + const base = dateParam() ?? new Date(`${kstDateKey(renderToday)}T00:00:00Z`); + base.setUTCDate(base.getUTCDate() + days); + updateDateQuery(kstDateKey(base)); }; const date = { current: (): Date => dateParam() ?? renderToday, /** date 파라미터가 없을 때 매 렌더 새 Date를 반환하므로, 그대로 key로 쓰면 서브트리가 매번 재생성된다. */ key: (Array.isArray(query[DATE_KEY]) ? query[DATE_KEY][0] : query[DATE_KEY]) ?? 'today', - checkToday: (d: Date) => renderToday.toDateString() === d.toDateString(), - checkTomorrow: (d: Date) => renderTomorrow.toDateString() === d.toDateString(), - checkPast: (d: Date) => renderToday > d, + checkToday: (d: Date) => kstDateKey(renderToday) === kstDateKey(d), + checkTomorrow: (d: Date) => kstDateKey(renderTomorrow) === kstDateKey(d), + checkPast: (d: Date) => kstDateKey(renderToday) > kstDateKey(d), setPrev: () => shiftDate(-1), setNext: () => shiftDate(1), setPrevWeek: () => shiftDate(-7), setNextWeek: () => shiftDate(7), - set: (d: Date) => { - const formatted = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - updateDateQuery(formatted); - }, - setToday: () => updateDateQuery(renderToday.toISOString().slice(0, 10)), + set: (d: Date) => updateDateQuery(kstDateKey(d)), + setToday: () => updateDateQuery(kstDateKey(renderToday)), }; const diningType = DINING_TYPES.find((t) => t === query[TYPE_KEY]) ?? new DiningTime(renderToday).getType(); diff --git a/apps/web/src/components/cafeteria/utils/time.ts b/apps/web/src/components/cafeteria/utils/time.ts index d812eb4c..4868ac4e 100644 --- a/apps/web/src/components/cafeteria/utils/time.ts +++ b/apps/web/src/components/cafeteria/utils/time.ts @@ -1,40 +1,7 @@ import type { DiningType } from 'api/dinings/entity'; +import { kstDateKey, kstHour, kstTimeOn } from 'utils/ts/calendar'; -export const convertDateToSimpleString = (date: Date) => - `${date.getFullYear().toString().slice(2, 4)}${(date.getMonth() + 1).toString().padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}`; - -const KST_OFFSET_MINUTES = 9 * 60; - -// getHours/setHours는 실행 환경 타임존을 따라 서버·클라이언트가 다른 값을 낸다. -// 고정 +9시간 오프셋으로 직접 계산하면 실행 환경과 무관하게 항상 KST 기준이 된다(한국은 DST 없음). -export function kstTimeOn(base: Date, hours: number, minutes: number): Date { - const kstNow = new Date(base.getTime() + KST_OFFSET_MINUTES * 60 * 1000); - const y = kstNow.getUTCFullYear(); - const m = kstNow.getUTCMonth(); - const d = kstNow.getUTCDate(); - - return new Date(Date.UTC(y, m, d, hours, minutes) - KST_OFFSET_MINUTES * 60 * 1000); -} - -function kstHour(date: Date): number { - const kst = new Date(date.getTime() + KST_OFFSET_MINUTES * 60 * 1000); - - return kst.getUTCHours(); -} - -/** KST 기준 요일(0=일 ~ 6=토). 실행 환경 타임존과 무관하다. */ -export function kstDayOfWeek(date: Date): number { - const kst = new Date(date.getTime() + KST_OFFSET_MINUTES * 60 * 1000); - - return kst.getUTCDay(); -} - -/** KST 기준 달력일 키(YYYY-MM-DD). 날짜가 바뀌었는지 실행 환경 타임존과 무관하게 비교할 때 쓴다. */ -export function kstDateKey(date: Date): string { - const kst = new Date(date.getTime() + KST_OFFSET_MINUTES * 60 * 1000); - - return kst.toISOString().slice(0, 10); -} +export const convertDateToSimpleString = (date: Date) => kstDateKey(date).slice(2).replaceAll('-', ''); export class DiningTime { private now: Date; @@ -75,16 +42,7 @@ export class DiningTime { return kstHour(this.now) >= 19; } - // setDate/getDate는 로컬 타임존 기준이라 자정 근처에는 하루 어긋날 수 있다. - // convertDateToSimpleString 등 cafeteria 날짜 처리 전체가 같은 전제라 이 함수만 고치지 않는다. public generateDiningDate() { - if (this.isTodayDining()) { - return new Date(this.now); - } - - const tomorrow = new Date(this.now); - tomorrow.setDate(this.now.getDate() + 1); - - return tomorrow; + return new Date(this.now.getTime() + (this.isTodayDining() ? 0 : 24 * 60 * 60 * 1000)); } } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 274ea41c..a88aefc9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,8 @@ import { NotFoundPage } from 'routes/404.index'; import { isDisabledOrderPath, isProtectedPath } from 'static/routeAccess'; import ROUTES from 'static/routes'; import { parseSearch, stringifySearch } from 'utils/routing/search'; +import { getClientRootSnapshot, type RootSnapshot } from 'utils/ssr/pageLoader'; +import type { ServerRequestContext } from 'utils/ssr/requestContext'; import { getRootSnapshot } from 'utils/ssr/rootContext.functions'; import 'pretendard/dist/web/static/pretendard-dynamic-subset.css'; @@ -26,9 +28,15 @@ import 'styles/global.scss'; export const Route = createRootRoute({ validateSearch: (search: Record): Record => parseSearch(stringifySearch(search)), - beforeLoad: async ({ location }) => { + beforeLoad: async ({ location, matches }): Promise => { if (isDisabledOrderPath(location.pathname)) throw notFound(); - const snapshot = await getRootSnapshot(); + // 하이드레이션 이후 화면 이동은 서버 함수를 거치지 않고 SSR 스냅샷과 브라우저 세션으로 만든다. + // 루트 매치는 항상 matches[0]이고 loaderData는 아래 loader가 돌려준 값이다. 자기 참조라 타입을 직접 적는다 + const rootLoaderData = matches[0]?.loaderData as { serverRequest?: ServerRequestContext } | undefined; + const snapshot = + typeof window !== 'undefined' && rootLoaderData?.serverRequest + ? await getClientRootSnapshot(rootLoaderData.serverRequest) + : await getRootSnapshot(); if (isProtectedPath(location.pathname) && !snapshot.hasSession) { throw redirect({ href: ROUTES.Auth() + '?redirect=' + encodeURIComponent(location.href) }); } diff --git a/apps/web/src/routes/articles.$id.index.tsx b/apps/web/src/routes/articles.$id.index.tsx index b02e7e50..7e820253 100644 --- a/apps/web/src/routes/articles.$id.index.tsx +++ b/apps/web/src/routes/articles.$id.index.tsx @@ -12,6 +12,7 @@ import MobileArticleDetailFooter from 'components/Articles/components/MobileArti import { isNewArticle } from 'components/Articles/utils/setArticleRegisteredDate'; import { SSRLayout } from 'components/layout'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS, isNotFoundKoinError, withStaticFetchRetry } from 'utils/ts/isr'; function ArticlesDetailPage({ article, hotArticles }: { article: ArticleResponseWithNew; hotArticles?: HotArticle[] }) { @@ -32,57 +33,58 @@ function ArticlesDetailPage({ article, hotArticles }: { article: ArticleResponse ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async ({ params }, cacheControl) => { - const { id } = params; - if (!id) { - cacheControl.enablePublicCache( - `public, s-maxage=${ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, - ); - throw notFound(); - } +const loadPage = definePageLoad(async ({ params }, cacheControl, serverRequest) => { + const { id } = params; + if (!id) { + cacheControl.enablePublicCache( + `public, s-maxage=${ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, + ); + throw notFound(); + } - try { - const article = await withStaticFetchRetry('article.detail', () => getArticle(id)); - const hotArticles = (await withStaticFetchRetry('article.hot', () => getHotArticles()).catch(() => [])) ?? []; - const serverTime = new Date(); + try { + const article = await withStaticFetchRetry('article.detail', () => getArticle(id)); + const hotArticles = (await withStaticFetchRetry('article.hot', () => getHotArticles()).catch(() => [])) ?? []; + const serverTime = new Date(serverRequest.now); - const articleWithNew = traceOperation( - { - name: 'Transform article detail', - op: 'koin.isr.transform', - onlyIfParent: true, - attributes: { 'isr.resource': 'article.detail' }, - }, - (): ArticleResponseWithNew => ({ - ...article, - isNew: isNewArticle(article.registered_at, serverTime), - }), - ); + const articleWithNew = traceOperation( + { + name: 'Transform article detail', + op: 'koin.isr.transform', + onlyIfParent: true, + attributes: { 'isr.resource': 'article.detail' }, + }, + (): ArticleResponseWithNew => ({ + ...article, + isNew: isNewArticle(article.registered_at, serverTime), + }), + ); - cacheControl.enablePublicCache( - `public, s-maxage=${ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, - ); + cacheControl.enablePublicCache( + `public, s-maxage=${ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, + ); - return { article: articleWithNew, hotArticles }; - } catch (error) { - if (isNotFoundKoinError(error)) { - cacheControl.enablePublicCache( - `public, s-maxage=${ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, - ); - throw notFound(); - } - throw error; - } - }), - ); + return { article: articleWithNew, hotArticles }; + } catch (error) { + if (isNotFoundKoinError(error)) { + cacheControl.enablePublicCache( + `public, s-maxage=${ARTICLE_DETAIL_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, + ); + throw notFound(); + } + throw error; + } +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/articles/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/articles.index.tsx b/apps/web/src/routes/articles.index.tsx index 3487d042..12e09d77 100644 --- a/apps/web/src/routes/articles.index.tsx +++ b/apps/web/src/routes/articles.index.tsx @@ -1,4 +1,4 @@ -import { dehydrate, keepPreviousData, QueryClient, useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { createFileRoute, useSearch, useParams } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -15,6 +15,7 @@ import HomeLayout from 'components/layout/HomeLayout'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; const DEFAULT_BOARD_ID = 4; @@ -82,40 +83,35 @@ function ArticleListPage({ initialPage, initialBoardId, serverNow }: Awaited - loadPageData(data, async (context, cacheControl, serverRequest) => { - const { search: query } = context; - const pageNumber = typeof query.page === 'string' ? query.page : '1'; - const boardId = typeof query.boardId === 'string' ? Number(query.boardId) : DEFAULT_BOARD_ID; - - const queryClient = new QueryClient(); +const loadPage = definePageLoad(async ({ search: query, queryClient }, cacheControl, serverRequest) => { + const pageNumber = typeof query.page === 'string' ? query.page : '1'; + const boardId = typeof query.boardId === 'string' ? Number(query.boardId) : DEFAULT_BOARD_ID; - const prefetchPromises = [ - queryClient.prefetchQuery(articleQueries.hot()), - queryClient.prefetchQuery(articleQueries.list(serverRequest.isLoggedIn, pageNumber, boardId)), - ]; + await Promise.all([ + queryClient.prefetchQuery(articleQueries.hot()), + queryClient.prefetchQuery(articleQueries.list(serverRequest.isLoggedIn, pageNumber, boardId)), + ]); - await Promise.all(prefetchPromises); + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(); + } - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(); - } + return { + initialPage: pageNumber, + serverNow: serverRequest.now, + initialBoardId: boardId, + }; +}); - return { - dehydratedState: dehydrate(queryClient), - initialPage: pageNumber, - serverNow: new Date().toISOString(), - initialBoardId: boardId, - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/articles/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/benefitstore.index.tsx b/apps/web/src/routes/benefitstore.index.tsx index 8f723a19..fb431e2c 100644 --- a/apps/web/src/routes/benefitstore.index.tsx +++ b/apps/web/src/routes/benefitstore.index.tsx @@ -1,12 +1,6 @@ import { Suspense, useEffect } from 'react'; import { cn } from '@bcsdlab/utils'; -import { - dehydrate, - HydrationBoundary, - QueryClient, - useSuspenseQuery, - type DehydratedState, -} from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -20,7 +14,8 @@ import { STORE_PAGE } from 'static/store'; import useLogger from 'utils/hooks/analytics/useLogger'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useParamsHandler from 'utils/hooks/routing/useParamsHandler'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import styles from 'pages/benefitstore/StoreBenefitPage.module.scss'; @@ -117,54 +112,49 @@ function StoreBenefit() { ); } -function StoreBenefitPage({ dehydratedState }: { dehydratedState: DehydratedState }) { +function StoreBenefitPage() { + // TODO: Loading 디자인 추가 요청 return ( - - {/* TODO: Loading 디자인 추가 요청 */} - - - - + + + ); } +const loadPage = definePageLoad(async ({ search, queryClient }, cacheControl) => { + const { category } = search; + const categoryId = Array.isArray(category) ? category[0] : category; + + if (!categoryId) { + throw notFound(); + } + + await Promise.all([ + queryClient.prefetchQuery(storeQueries.benefitCategory()), + queryClient.prefetchQuery(storeQueries.benefitList(categoryId)), + // StoreList 페이지에서 사용하는 API + queryClient.prefetchQuery(storeQueries.categories()), + ]); + + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl) => { - const queryClient = new QueryClient(); - const { category } = context.search; - const categoryId = Array.isArray(category) ? category[0] : category; - - if (!categoryId) { - throw notFound(); - } - - await Promise.all([ - queryClient.prefetchQuery(storeQueries.benefitCategory()), - queryClient.prefetchQuery(storeQueries.benefitList(categoryId)), - // StoreList 페이지에서 사용하는 API - queryClient.prefetchQuery(storeQueries.categories()), - ]); - - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/benefitstore/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); function RouteComponent() { - const props = Route.useLoaderData(); - const page = ; + const page = ; return {page}; } diff --git a/apps/web/src/routes/bus.route.index.tsx b/apps/web/src/routes/bus.route.index.tsx index 7e411515..3534bfe4 100644 --- a/apps/web/src/routes/bus.route.index.tsx +++ b/apps/web/src/routes/bus.route.index.tsx @@ -1,5 +1,4 @@ import { Suspense, useState } from 'react'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -17,6 +16,7 @@ import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useBooleanState from 'utils/hooks/state/useBooleanState'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import styles from 'pages/bus/route/BusRoutePage.module.scss'; @@ -85,24 +85,23 @@ function BusRoutePage() { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl) => { - const queryClient = new QueryClient(); +const loadPage = definePageLoad(async ({ queryClient }, cacheControl) => { + await queryClient.prefetchQuery(busQueries.notice()); - await queryClient.prefetchQuery(busQueries.notice()); + cacheControl.enablePublicCache(`public, s-maxage=${BUS_NOTICE_REVALIDATE_SECONDS}, stale-while-revalidate=300`); - cacheControl.enablePublicCache(`public, s-maxage=${BUS_NOTICE_REVALIDATE_SECONDS}, stale-while-revalidate=300`); + return {}; +}); - return { dehydratedState: dehydrate(queryClient) }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/bus/route/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/bus.shuttle.index.tsx b/apps/web/src/routes/bus.shuttle.index.tsx index b0c2346b..6b37c489 100644 --- a/apps/web/src/routes/bus.shuttle.index.tsx +++ b/apps/web/src/routes/bus.shuttle.index.tsx @@ -1,4 +1,4 @@ -import { dehydrate, QueryClient, useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch, useParams } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import dayjs from 'dayjs'; @@ -17,6 +17,7 @@ import useLogger from 'utils/hooks/analytics/useLogger'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { BUS_SHUTTLE_ISR_REVALIDATE_SECONDS, withStaticFetchRetry } from 'utils/ts/isr'; import styles from 'pages/bus/shuttle/ShuttleBusTimetable.module.scss'; @@ -225,36 +226,31 @@ function TemplateShuttleVersion({ region, routes, category }: TemplateShuttleVer ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl) => { - const queryClient = new QueryClient(); +const loadPage = definePageLoad(async ({ queryClient }, cacheControl) => { + await Promise.all([ + withStaticFetchRetry('bus.shuttle-course', () => + queryClient.fetchQuery({ + ...busQueries.shuttleCourse(), + staleTime: 1000 * 60 * 10, + }), + ), + queryClient.fetchQuery(busQueries.notice()), + ]); - await Promise.all([ - withStaticFetchRetry('bus.shuttle-course', () => - queryClient.fetchQuery({ - ...busQueries.shuttleCourse(), - staleTime: 1000 * 60 * 10, - }), - ), - queryClient.fetchQuery(busQueries.notice()), - ]); + cacheControl.enablePublicCache(`public, s-maxage=${BUS_SHUTTLE_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); - cacheControl.enablePublicCache( - `public, s-maxage=${BUS_SHUTTLE_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`, - ); + return {}; +}); - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/bus/shuttle/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/cafeteria.index.tsx b/apps/web/src/routes/cafeteria.index.tsx index b14d8a41..2581dad8 100644 --- a/apps/web/src/routes/cafeteria.index.tsx +++ b/apps/web/src/routes/cafeteria.index.tsx @@ -1,11 +1,5 @@ import { useEffect, useRef } from 'react'; -import { - dehydrate, - type DehydratedState, - HydrationBoundary, - QueryClient, - useSuspenseQuery, -} from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -20,7 +14,7 @@ import { useCafeteriaParams } from 'components/cafeteria/hooks/useCafeteriaParam import MobileCafeteriaPage from 'components/cafeteria/MobileCafeteriaPage'; import SoldoutReportModal from 'components/cafeteria/MobileCafeteriaPage/components/SoldoutReportModal'; import PCCafeteriaPage from 'components/cafeteria/PCCafeteriaPage'; -import { convertDateToSimpleString, kstDayOfWeek, kstTimeOn } from 'components/cafeteria/utils/time'; +import { convertDateToSimpleString } from 'components/cafeteria/utils/time'; import showMobileToast from 'components/feedback/Toast/showMobileToast'; import Layout from 'components/layout'; import MobilePageHeader from 'components/layout/MobilePageHeader'; @@ -35,6 +29,8 @@ import useModalPortal from 'utils/hooks/layout/useModalPortal'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; +import { kstDayOfWeek, kstTimeOn } from 'utils/ts/calendar'; import styles from 'pages/cafeteria/Cafeteria.module.scss'; @@ -165,52 +161,46 @@ function Cafeteria() { } interface CafeteriaPageProps { - dehydratedState: DehydratedState; serverNowISO: string; } -function CafeteriaPage({ dehydratedState, serverNowISO }: CafeteriaPageProps) { +function CafeteriaPage({ serverNowISO }: CafeteriaPageProps) { return ( - - - - - + + + ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl) => { - const queryClient = new QueryClient(); - const { date } = context.search; +const loadPage = definePageLoad(async ({ search, queryClient }, cacheControl, serverRequest) => { + const { date } = search; - // date 쿼리 유무와 무관한, 요청을 받은 실제 시각. 렌더 경로의 "오늘" 기준값으로 내려보낸다. - const serverNow = new Date(); + // date 쿼리 유무와 무관하게 요청 스냅샷의 시각을 렌더 경로의 "오늘" 기준값으로 쓴다. + const serverNowISO = serverRequest.now; + const serverNow = new Date(serverNowISO); + const currentDate = date ? new Date(Array.isArray(date) ? date[0] : date) : serverNow; - const currentDate = date ? new Date(Array.isArray(date) ? date[0] : date) : serverNow; + const convertedDate = convertDateToSimpleString(currentDate); - const convertedDate = convertDateToSimpleString(currentDate); + await Promise.all([ + queryClient.prefetchQuery(cafeteriaQueries.dinings(convertedDate)), + queryClient.prefetchQuery(coopshopQueries.cafeteriaInfo()), + ]); - await Promise.all([ - queryClient.prefetchQuery(cafeteriaQueries.dinings(convertedDate)), - queryClient.prefetchQuery(coopshopQueries.cafeteriaInfo()), - ]); + cacheControl.enablePublicCache(); - cacheControl.enablePublicCache(); + return { serverNowISO }; +}); - return { - dehydratedState: dehydrate(queryClient), - serverNowISO: serverNow.toISOString(), - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/cafeteria/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { hasPageSeo: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/callvan.$postId.participants.index.tsx b/apps/web/src/routes/callvan.$postId.participants.index.tsx index 07063e26..88fdf96f 100644 --- a/apps/web/src/routes/callvan.$postId.participants.index.tsx +++ b/apps/web/src/routes/callvan.$postId.participants.index.tsx @@ -1,5 +1,4 @@ import { Suspense, useEffect } from 'react'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -9,6 +8,7 @@ import ROUTES from 'static/routes'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; function CallvanParticipantsPage({ postId }: Awaited>) { const routeNavigate = useNavigate(); @@ -33,37 +33,34 @@ function CallvanParticipantsPage({ postId }: Awaited - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - const { isLoggedIn } = serverRequest; - const postId = Number(context.params.postId); +const loadPage = definePageLoad(async ({ params, queryClient }, _cacheControl, serverRequest) => { + const { isLoggedIn } = serverRequest; + const postId = Number(params.postId); - if (!postId || Number.isNaN(postId)) { - throw notFound(); - } + if (!postId || Number.isNaN(postId)) { + throw notFound(); + } + + try { + if (isLoggedIn) { + await queryClient.prefetchQuery(callvanQueries.postDetail(postId, isLoggedIn)); + } + } catch (error) { + console.error('[SSR] callvan post detail prefetch failed:', error); + } - try { - if (isLoggedIn) { - await queryClient.prefetchQuery(callvanQueries.postDetail(postId, isLoggedIn)); - } - } catch (error) { - console.error('[SSR] callvan post detail prefetch failed:', error); - } + return { postId }; +}); - return { - dehydratedState: dehydrate(queryClient), - postId, - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/callvan/$postId/participants/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/callvan.$postId.report.$userId.index.tsx b/apps/web/src/routes/callvan.$postId.report.$userId.index.tsx index 49739353..ed041458 100644 --- a/apps/web/src/routes/callvan.$postId.report.$userId.index.tsx +++ b/apps/web/src/routes/callvan.$postId.report.$userId.index.tsx @@ -7,6 +7,7 @@ import ROUTES from 'static/routes'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface CallvanReportPageProps { postId: number; @@ -32,28 +33,29 @@ function CallvanReportPage({ postId, userId }: CallvanReportPageProps) { return ; } +const loadPage = definePageLoad(({ params }) => { + const postId = Number(params.postId); + const userId = Number(params.userId); + + if (!postId || Number.isNaN(postId) || !userId || Number.isNaN(userId)) { + throw notFound(); + } + + return { + postId, + userId, + }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, (context) => { - const postId = Number(context.params.postId); - const userId = Number(context.params.userId); - - if (!postId || Number.isNaN(postId) || !userId || Number.isNaN(userId)) { - throw notFound(); - } - - return { - postId, - userId, - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/callvan/$postId/report/$userId/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/callvan.index.tsx b/apps/web/src/routes/callvan.index.tsx index b7acc29a..88ddc209 100644 --- a/apps/web/src/routes/callvan.index.tsx +++ b/apps/web/src/routes/callvan.index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { dehydrate, QueryClient, useInfiniteQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch, useParams, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -14,6 +14,7 @@ import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useMount from 'utils/hooks/state/useMount'; import useInfiniteScroll from 'utils/hooks/ui/useInfiniteScroll'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import listStyles from 'components/Callvan/components/CallvanList/CallvanList.module.scss'; @@ -112,44 +113,41 @@ function CallvanContent({ params }: CallvanContentProps) { ); } +const loadPage = definePageLoad(async ({ search: query, queryClient }, _cacheControl, serverRequest) => { + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.Main(), statusCode: 302 }); + } + + const params = parseCallvanQuery(query, DEFAULT_PARAMS); + const apiParams = toCallvanApiParams(params); + + const { isLoggedIn } = serverRequest; + + try { + await Promise.all([ + queryClient.prefetchInfiniteQuery(callvanQueries.infiniteList(apiParams, isLoggedIn)), + isLoggedIn + ? queryClient.prefetchQuery(callvanQueries.notifications(isLoggedIn)) + : queryClient.setQueryData(callvanQueryKeys.notifications(isLoggedIn), []), + ]); + } catch (error) { + console.error('[SSR] callvan prefetch failed:', error); + } + + return { + initialParams: params, + }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.Main(), statusCode: 302 }); - } - - const queryClient = new QueryClient(); - const { search: query } = context; - - const params = parseCallvanQuery(query, DEFAULT_PARAMS); - const apiParams = toCallvanApiParams(params); - - const { isLoggedIn } = serverRequest; - - try { - await Promise.all([ - queryClient.prefetchInfiniteQuery(callvanQueries.infiniteList(apiParams, isLoggedIn)), - isLoggedIn - ? queryClient.prefetchQuery(callvanQueries.notifications(isLoggedIn)) - : queryClient.setQueryData(callvanQueryKeys.notifications(isLoggedIn), []), - ]); - } catch (error) { - console.error('[SSR] callvan prefetch failed:', error); - } - - return { - dehydratedState: dehydrate(queryClient), - initialParams: params, - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/callvan/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/callvan.notifications.index.tsx b/apps/web/src/routes/callvan.notifications.index.tsx index d977aa7c..74f3cc94 100644 --- a/apps/web/src/routes/callvan.notifications.index.tsx +++ b/apps/web/src/routes/callvan.notifications.index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useRouter, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -18,6 +18,7 @@ import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import styles from 'pages/callvan/notifications/CallvanNotifications.module.scss'; @@ -117,37 +118,35 @@ function CallvanNotificationsPage() { ); } +const loadPage = definePageLoad(async ({ queryClient }, _cacheControl, serverRequest) => { + // 모바일 전용 화면이므로 데스크톱은 메인으로 돌려보낸다. + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.Main(), statusCode: 302 }); + } + + const { isLoggedIn } = serverRequest; + + try { + if (isLoggedIn) { + await queryClient.prefetchQuery(callvanQueries.notifications(isLoggedIn)); + } else { + queryClient.setQueryData(callvanQueryKeys.notifications(isLoggedIn), []); + } + } catch (error) { + console.error('[SSR] callvan notifications prefetch failed:', error); + } + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, _cacheControl, serverRequest) => { - // 모바일 전용 화면이다. 데스크톱은 서버에서 바로 돌려보낸다. - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.Main(), statusCode: 302 }); - } - - const queryClient = new QueryClient(); - const { isLoggedIn } = serverRequest; - - try { - if (isLoggedIn) { - await queryClient.prefetchQuery(callvanQueries.notifications(isLoggedIn)); - } else { - queryClient.setQueryData(callvanQueryKeys.notifications(isLoggedIn), []); - } - } catch (error) { - console.error('[SSR] callvan notifications prefetch failed:', error); - } - - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/callvan/notifications/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: CallvanNotificationsPage, }); diff --git a/apps/web/src/routes/clubs.$id.index.tsx b/apps/web/src/routes/clubs.$id.index.tsx index 868e0aff..b707ee14 100644 --- a/apps/web/src/routes/clubs.$id.index.tsx +++ b/apps/web/src/routes/clubs.$id.index.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { cn } from '@bcsdlab/utils'; -import { dehydrate, QueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch, useParams, useLocation, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -38,6 +38,7 @@ import useBooleanState from 'utils/hooks/state/useBooleanState'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import { stringifySearch } from 'utils/routing/search'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { copyToClipboard } from 'utils/ts/copyToClipboard'; import { formatPhoneNumber } from 'utils/ts/formatPhoneNumber'; import { closeWebView } from 'utils/ts/nativeCommands'; @@ -768,54 +769,51 @@ function ClubDetailPage({ initialClubId, initialTab, initialEventId }: ClubDetai ); } +const loadPage = definePageLoad(async ({ params, search: query, queryClient }, cacheControl, serverRequest) => { + const { id } = params; + + if (!id) { + throw notFound(); + } + + const clubId = Number(id); + const tab = query.tab as TabType | undefined; + const eventId = query.eventId as string | undefined; + const numericEventId = eventId ? Number(eventId) : NO_SELECTED_EVENT_ID; + + let initialTab: TabType = tab ?? 'intro'; + if (!tab && eventId) { + initialTab = 'event'; + } + + const shouldPrefetchEventDetail = initialTab === 'event' && numericEventId !== NO_SELECTED_EVENT_ID; + + await Promise.all([ + queryClient.prefetchQuery(clubQueries.detail(clubId, serverRequest.isLoggedIn)), + queryClient.prefetchQuery(clubQueries.recruitment(clubId)), + shouldPrefetchEventDetail && queryClient.prefetchQuery(clubQueries.eventDetail(clubId, numericEventId)), + ]); + + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(); + } + + return { + initialClubId: clubId, + initialTab, + initialEventId: numericEventId, + }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const { params, search: query } = context; - const { id } = params; - - if (!id) { - throw notFound(); - } - - const clubId = Number(id); - const tab = query.tab as TabType | undefined; - const eventId = query.eventId as string | undefined; - const numericEventId = eventId ? Number(eventId) : NO_SELECTED_EVENT_ID; - - let initialTab: TabType = tab ?? 'intro'; - if (!tab && eventId) { - initialTab = 'event'; - } - - const queryClient = new QueryClient(); - - const shouldPrefetchEventDetail = initialTab === 'event' && numericEventId !== NO_SELECTED_EVENT_ID; - - await Promise.all([ - queryClient.prefetchQuery(clubQueries.detail(clubId, serverRequest.isLoggedIn)), - queryClient.prefetchQuery(clubQueries.recruitment(clubId)), - shouldPrefetchEventDetail && queryClient.prefetchQuery(clubQueries.eventDetail(clubId, numericEventId)), - ]); - - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(); - } - - return { - dehydratedState: dehydrate(queryClient), - initialClubId: clubId, - initialTab, - initialEventId: numericEventId, - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/clubs/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/clubs.index.tsx b/apps/web/src/routes/clubs.index.tsx index a45a14bf..fe5f2251 100644 --- a/apps/web/src/routes/clubs.index.tsx +++ b/apps/web/src/routes/clubs.index.tsx @@ -1,5 +1,5 @@ import { cn } from '@bcsdlab/utils'; -import { dehydrate, QueryClient, useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -24,6 +24,7 @@ import useParamsHandler from 'utils/hooks/routing/useParamsHandler'; import useBooleanState from 'utils/hooks/state/useBooleanState'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { createQueryParser, parseQueryBoolean, @@ -348,42 +349,40 @@ function ClubListPage({ initialQuery }: Awaited>) ); } +const loadPage = definePageLoad(async ({ search, queryClient }, cacheControl, serverRequest) => { + const params = parseClubListQuery(search); + + await Promise.all([ + queryClient.prefetchQuery(clubQueries.categories()), + queryClient.prefetchQuery( + clubQueries.list({ + isLoggedIn: serverRequest.isLoggedIn, + categoryId: params.categoryId ?? undefined, + sortType: params.sortType, + isRecruiting: params.isRecruiting, + clubName: params.clubName, + }), + ), + ]); + + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(); + } + + return { + initialQuery: params, + }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const params = parseClubListQuery(context.search); - - const queryClient = new QueryClient(); - - await Promise.all([ - queryClient.prefetchQuery(clubQueries.categories()), - queryClient.prefetchQuery( - clubQueries.list({ - isLoggedIn: serverRequest.isLoggedIn, - categoryId: params.categoryId ?? undefined, - sortType: params.sortType, - isRecruiting: params.isRecruiting, - clubName: params.clubName, - }), - ), - ]); - - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(); - } - - return { - dehydratedState: dehydrate(queryClient), - initialQuery: params, - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/clubs/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/department.$category.index.tsx b/apps/web/src/routes/department.$category.index.tsx index 3aec2c12..53346c0c 100644 --- a/apps/web/src/routes/department.$category.index.tsx +++ b/apps/web/src/routes/department.$category.index.tsx @@ -5,6 +5,7 @@ import { DEPARTMENT_CONTACT_CATEGORIES, type DepartmentContactCategory } from 'a import CategoryDetailPage from 'components/Department/CategoryDetail'; import Layout from 'components/layout'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; function isDepartmentContactCategory(value: string | string[] | undefined): value is DepartmentContactCategory { return typeof value === 'string' && (DEPARTMENT_CONTACT_CATEGORIES as string[]).includes(value); @@ -25,21 +26,22 @@ function DepartmentCategoryRoutePage() { return ; } -// 경로 카테고리는 서버에서 허용 목록으로 검증하므로 잘못된 주소도 HTTP 404로 응답한다. +// 경로 카테고리는 허용 목록으로 검증하므로 잘못된 주소는 404로 응답한다. +const loadPage = definePageLoad(({ params }) => { + if (!isDepartmentContactCategory(params.category)) throw notFound(); + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, ({ params }) => { - if (!isDepartmentContactCategory(params.category)) throw notFound(); - - return {}; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/department/$category/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a69bc15b..9de18964 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -17,6 +16,7 @@ import HomePage from 'components/IndexComponents/HomePage'; import HomeLayout from 'components/layout/HomeLayout'; import { isKoinError } from 'interfaces/APIError'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { getRecentSemester, resolveTimetableSemester } from 'utils/timetable/semester'; import { isUnauthorizedError } from 'utils/ts/httpError'; @@ -24,150 +24,150 @@ function Index(props: Awaited>) { return ; } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - let isLoggedIn = serverRequest.isLoggedIn; - let userType = serverRequest.userType; - const isMobile = serverRequest.device === 'mobile'; - - const resetAuthContext = () => { - isLoggedIn = false; - userType = null; - serverRequest.isLoggedIn = false; - serverRequest.userType = null; - }; - - const setDefaultTimetableFrameList = (semester = getRecentSemester()) => { - queryClient.setQueryData(timetableQueryKeys.frameList(semester, isLoggedIn), createDefaultTimetableFrameList()); - }; - - const fetchMySemester = async () => { - if (!isLoggedIn || userType !== 'STUDENT') return null; - - try { - return await queryClient.fetchQuery(timetableQueries.mySemester(isLoggedIn, { userType })); - } catch (error) { - if (isUnauthorizedError(error)) { - resetAuthContext(); - - return null; - } - if (isKoinError(error) && error.status === 403) { - return null; - } - throw error; - } - }; - - const diningTime = new DiningTime(); - const diningDate = diningTime.generateDiningDate(); - const serverDining = { - type: diningTime.getType(), - dayLabel: diningTime.isTodayDining() ? '오늘' : '내일', - date: diningDate.toISOString(), - }; - - const prefetchTimetable = async () => { - const mySemester = await fetchMySemester(); - const userSemester = mySemester?.semesters?.[0]; - - if (isMobile || !isLoggedIn || userType !== 'STUDENT') return userSemester; - - if (!userSemester) { - setDefaultTimetableFrameList(); - - return userSemester; - } - - try { - const timetableFrameList = await queryClient.fetchQuery( - timetableQueries.frameList(isLoggedIn, userSemester, { userType }), - ); - const mainFrame = timetableFrameList.find((frame) => frame.is_main); - const activeMainFrameId = mainFrame?.id; - if (typeof activeMainFrameId === 'number') { - await queryClient.prefetchQuery(timetableQueries.lectureInfo(isLoggedIn, activeMainFrameId)); - } - } catch (error) { - if (isUnauthorizedError(error)) { - resetAuthContext(); - } else if (isKoinError(error) && (error.status === 403 || error.status === 404)) { - setDefaultTimetableFrameList(userSemester); - } else { - throw error; - } - } - - return userSemester; - }; - - const prefetchBanners = async () => { - const banners = await queryClient.fetchQuery(bannerQueries.categories()); - const category = banners.banner_categories[0]; - if (!category) return null; - - const categoryId = Number(category.id); - - return queryClient.fetchQuery(bannerQueries.list(categoryId)); - }; - - let bannersList: BannersResponse = { count: 0, banners: [] }; - - const prefetchDeviceContent = async () => { - if (isMobile) { - await Promise.all([ - queryClient.prefetchQuery( - callvanQueries.list({ statuses: ['RECRUITING'], sort: 'LATEST_DESC', page: 1, limit: 1 }), - ), - queryClient.prefetchQuery(coopshopQueries.cafeteriaInfo()), - ]); - - return; - } - - const [banners] = await Promise.all([ - prefetchBanners(), - queryClient.prefetchQuery(articleQueries.list(isLoggedIn, '1')), - queryClient.prefetchQuery(timetableQueries.semesterInfo()), - ]); - if (banners) { - bannersList = banners; - } - }; - - const [userSemester] = await Promise.all([ - prefetchTimetable(), - queryClient.prefetchQuery(weatherQueries.info()), - queryClient.prefetchQuery(storeQueries.counts()), - queryClient.prefetchQuery(storeQueries.eventCount()), - queryClient.prefetchQuery(cafeteriaQueries.dinings(convertDateToSimpleString(diningDate))), - isLoggedIn && queryClient.prefetchQuery(authQueries.userInfo(true)), - prefetchDeviceContent(), - ]); +const loadPage = definePageLoad(async ({ queryClient }, cacheControl, serverRequest) => { + let isLoggedIn = serverRequest.isLoggedIn; + let userType = serverRequest.userType; + const isMobile = serverRequest.device === 'mobile'; + const serverNow = new Date(serverRequest.now); + + const resetAuthContext = () => { + isLoggedIn = false; + userType = null; + serverRequest.isLoggedIn = false; + serverRequest.userType = null; + }; + + const setDefaultTimetableFrameList = (semester = getRecentSemester(serverNow)) => { + queryClient.setQueryData(timetableQueryKeys.frameList(semester, isLoggedIn), createDefaultTimetableFrameList()); + }; + + const fetchMySemester = async () => { + if (!isLoggedIn || userType !== 'STUDENT') return null; + + try { + return await queryClient.fetchQuery(timetableQueries.mySemester(isLoggedIn, { userType })); + } catch (error) { + if (isUnauthorizedError(error)) { + resetAuthContext(); + + return null; + } + if (isKoinError(error) && error.status === 403) { + return null; + } + throw error; + } + }; + + const diningTime = new DiningTime(serverNow); + const diningDate = diningTime.generateDiningDate(); + const serverDining = { + type: diningTime.getType(), + dayLabel: diningTime.isTodayDining() ? '오늘' : '내일', + date: diningDate.toISOString(), + }; + + const prefetchTimetable = async () => { + const mySemester = await fetchMySemester(); + const userSemester = mySemester?.semesters?.[0]; + + if (isMobile || !isLoggedIn || userType !== 'STUDENT') return userSemester; + + if (!userSemester) { + setDefaultTimetableFrameList(); + + return userSemester; + } + + try { + const timetableFrameList = await queryClient.fetchQuery( + timetableQueries.frameList(isLoggedIn, userSemester, { userType }), + ); + const mainFrame = timetableFrameList.find((frame) => frame.is_main); + const activeMainFrameId = mainFrame?.id; + if (typeof activeMainFrameId === 'number') { + await queryClient.prefetchQuery(timetableQueries.lectureInfo(isLoggedIn, activeMainFrameId)); + } + } catch (error) { + if (isUnauthorizedError(error)) { + resetAuthContext(); + } else if (isKoinError(error) && (error.status === 403 || error.status === 404)) { + setDefaultTimetableFrameList(userSemester); + } else { + throw error; + } + } - const serverSemester = resolveTimetableSemester(undefined, undefined, userSemester) ?? getRecentSemester(); + return userSemester; + }; - if (!isLoggedIn) { - cacheControl.enablePublicCache(); - } + const prefetchBanners = async () => { + const banners = await queryClient.fetchQuery(bannerQueries.categories()); + const category = banners.banner_categories[0]; + if (!category) return null; + + const categoryId = Number(category.id); - return { - bannersList, - serverDining, - serverNow: serverRequest.now, - serverSemester, - dehydratedState: dehydrate(queryClient), - }; - }), - ); + return queryClient.fetchQuery(bannerQueries.list(categoryId)); + }; + + let bannersList: BannersResponse = { count: 0, banners: [] }; + + const prefetchDeviceContent = async () => { + if (isMobile) { + await Promise.all([ + queryClient.prefetchQuery( + callvanQueries.list({ statuses: ['RECRUITING'], sort: 'LATEST_DESC', page: 1, limit: 1 }), + ), + queryClient.prefetchQuery(coopshopQueries.cafeteriaInfo()), + ]); + + return; + } + + const [banners] = await Promise.all([ + prefetchBanners(), + queryClient.prefetchQuery(articleQueries.list(isLoggedIn, '1')), + queryClient.prefetchQuery(timetableQueries.semesterInfo()), + ]); + if (banners) { + bannersList = banners; + } + }; + + const [userSemester] = await Promise.all([ + prefetchTimetable(), + queryClient.prefetchQuery(weatherQueries.info()), + queryClient.prefetchQuery(storeQueries.counts()), + queryClient.prefetchQuery(storeQueries.eventCount()), + queryClient.prefetchQuery(cafeteriaQueries.dinings(convertDateToSimpleString(diningDate))), + isLoggedIn && queryClient.prefetchQuery(authQueries.userInfo(true)), + prefetchDeviceContent(), + ]); + + const serverSemester = resolveTimetableSemester(undefined, undefined, userSemester) ?? getRecentSemester(serverNow); + + if (!isLoggedIn) { + cacheControl.enablePublicCache(); + } + + return { + bannersList, + serverDining, + serverNow: serverRequest.now, + serverSemester, + }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/lost-item.$id.index.tsx b/apps/web/src/routes/lost-item.$id.index.tsx index cfe80f42..67db1146 100644 --- a/apps/web/src/routes/lost-item.$id.index.tsx +++ b/apps/web/src/routes/lost-item.$id.index.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { dehydrate, QueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useRouter, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -28,6 +28,7 @@ import useBooleanState from 'utils/hooks/state/useBooleanState'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import styles from 'pages/lost-item/[id]/LostItemDetailPage.module.scss'; @@ -245,42 +246,40 @@ function LostItemDetailPage({ articleId }: LostItemDetailPageProps) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (typeof id !== 'string') { - throw notFound(); - } - const articleId = Number(id); +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (typeof id !== 'string') { + throw notFound(); + } + const articleId = Number(id); - const queryClient = new QueryClient(); + const latestLostItemParams = { limit: 10, sort: 'LATEST' as const }; - const latestLostItemParams = { limit: 10, sort: 'LATEST' as const }; + await Promise.all([ + queryClient.prefetchQuery(articleQueries.lostItemDetail(serverRequest.isLoggedIn, articleId)), + queryClient.prefetchInfiniteQuery( + articleQueries.lostItemInfiniteList(serverRequest.isLoggedIn, latestLostItemParams), + ), + ]); - await Promise.all([ - queryClient.prefetchQuery(articleQueries.lostItemDetail(serverRequest.isLoggedIn, articleId)), - queryClient.prefetchInfiniteQuery( - articleQueries.lostItemInfiniteList(serverRequest.isLoggedIn, latestLostItemParams), - ), - ]); + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(); + } - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(); - } + return { + articleId, + }; +}); - return { - dehydratedState: dehydrate(queryClient), - articleId, - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/lost-item/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { hasPageSeo: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/lost-item.index.tsx b/apps/web/src/routes/lost-item.index.tsx index 159ad639..4c276dc1 100644 --- a/apps/web/src/routes/lost-item.index.tsx +++ b/apps/web/src/routes/lost-item.index.tsx @@ -1,4 +1,4 @@ -import { dehydrate, keepPreviousData, QueryClient, useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { createFileRoute, useSearch, useParams } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -13,6 +13,7 @@ import { SSRLayout } from 'components/layout'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import styles from 'pages/lost-item/LostItemArticleListPage.module.scss'; @@ -82,43 +83,40 @@ function LostItemArticleListPage({ initialParams }: Awaited { + const fallback: LostItemParams = { + page: 1, + type: null, + category: [], + foundStatus: 'ALL', + sort: 'LATEST', + author: 'ALL', + }; + + const params = parseLostItemQuery(query, fallback); + + const apiParams = toLostItemArticlesRequest(params); + + await queryClient.prefetchQuery(articleQueries.lostItemList(serverRequest.isLoggedIn, apiParams)); + + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(); + } + + return { + initialParams: params, + }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - const { search: query } = context; - - const fallback: LostItemParams = { - page: 1, - type: null, - category: [], - foundStatus: 'ALL', - sort: 'LATEST', - author: 'ALL', - }; - - const params = parseLostItemQuery(query, fallback); - - const apiParams = toLostItemArticlesRequest(params); - - await queryClient.prefetchQuery(articleQueries.lostItemList(serverRequest.isLoggedIn, apiParams)); - - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(); - } - - return { - dehydratedState: dehydrate(queryClient), - initialParams: params, - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/lost-item/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/lost-item.report.$id.index.tsx b/apps/web/src/routes/lost-item.report.$id.index.tsx index e1db792f..23207cc4 100644 --- a/apps/web/src/routes/lost-item.report.$id.index.tsx +++ b/apps/web/src/routes/lost-item.report.$id.index.tsx @@ -17,6 +17,7 @@ import ROUTES from 'static/routes'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import styles from 'pages/lost-item/report/[id]/ReportPage.module.scss'; @@ -62,28 +63,29 @@ function ReportPageWrapper() { } // 신고하기는 모바일 전용 화면이기에 데스크톱에서는 게시물 상세로 리다이랙션한다 -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, (context, _cacheControl, serverRequest) => { - const { id } = context.params; +const loadPage = definePageLoad(({ params }, _cacheControl, serverRequest) => { + const { id } = params; - if (!id) { - throw notFound(); - } + if (!id) { + throw notFound(); + } - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.LostItemDetail({ id }), statusCode: 302 }); - } + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.LostItemDetail({ id }), statusCode: 302 }); + } - return {}; - }), - ); + return {}; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/lost-item/report/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.cancel.$paymentId.index.tsx b/apps/web/src/routes/order.cancel.$paymentId.index.tsx index ed3f3878..64db2d0d 100644 --- a/apps/web/src/routes/order.cancel.$paymentId.index.tsx +++ b/apps/web/src/routes/order.cancel.$paymentId.index.tsx @@ -7,6 +7,7 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderCancelPage from 'components/Order/OrderCancelPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; @@ -30,21 +31,22 @@ function OrderCancelRoute({ isDesktop, paymentId }: Props) { } // 주문 취소는 사용자별 화면이라 공개 캐시를 켜지 않는다. order는 이 화면에서 조회 요청을 보내지 않는다 +const loadPage = definePageLoad(({ params }, _cacheControl, serverRequest) => { + const paymentId = parsePaymentId(params.paymentId); + if (!paymentId) throw notFound(); + + return { isDesktop: serverRequest.device !== 'mobile', paymentId }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, (context, _cacheControl, serverRequest) => { - const paymentId = parsePaymentId(context.params.paymentId); - if (!paymentId) throw notFound(); - - return { isDesktop: serverRequest.device !== 'mobile', paymentId }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/cancel/$paymentId/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.cart.index.tsx b/apps/web/src/routes/order.cart.index.tsx index 1362245e..52564470 100644 --- a/apps/web/src/routes/order.cart.index.tsx +++ b/apps/web/src/routes/order.cart.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, type DehydratedState, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -11,10 +10,10 @@ import OrderCartPage from 'components/Order/OrderCartPage'; import OrderCartHeader from 'components/Order/OrderCartPage/components/OrderCartHeader'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; - dehydratedState?: DehydratedState; } function OrderCartRoute({ isDesktop }: Props) { @@ -37,33 +36,33 @@ function OrderCartRoute({ isDesktop }: Props) { } // 장바구니는 사용자별 화면이라 공개 캐시를 켜지 않는다 -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, _cacheControl, serverRequest) => { - if (serverRequest.device !== 'mobile') { - return { isDesktop: true }; - } +const loadPage = definePageLoad(async ({ queryClient }, _cacheControl, serverRequest) => { + if (serverRequest.device !== 'mobile') { + return { isDesktop: true }; + } - // 로그인이면 장바구니(주문 유형 기본값 DELIVERY)를 서버에서 받아 본문까지 렌더한다. - // 만료된 쿠키 등으로 실패하면 결과를 내리지 않고(빈 장바구니로 확정하지 않는다) 클라이언트가 다시 조회한다. - // 비로그인은 조회하지 않고 빈 장바구니로 그린다(클라이언트가 order처럼 조회해 401이면 그대로 빈 장바구니) - const queryClient = new QueryClient(); - if (serverRequest.isLoggedIn) { - await queryClient.prefetchQuery({ - ...orderQueries.cart('DELIVERY', true), - queryFn: () => getCart('DELIVERY', true), - }); - } + // 로그인이면 장바구니(주문 유형 기본값 DELIVERY)를 미리 받아 본문까지 렌더한다. + // 만료된 쿠키 등으로 실패하면 결과를 내리지 않고(빈 장바구니로 확정하지 않는다) 클라이언트가 다시 조회한다. + // 비로그인은 조회하지 않고 빈 장바구니로 그린다(클라이언트가 order처럼 조회해 401이면 그대로 빈 장바구니) + if (serverRequest.isLoggedIn) { + await queryClient.prefetchQuery({ + ...orderQueries.cart('DELIVERY', true), + queryFn: () => getCart('DELIVERY', true), + }); + } - return { isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { isDesktop: false }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/cart/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.checkout.index.tsx b/apps/web/src/routes/order.checkout.index.tsx index 9613a73a..59b8ec53 100644 --- a/apps/web/src/routes/order.checkout.index.tsx +++ b/apps/web/src/routes/order.checkout.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, type DehydratedState, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -13,11 +12,11 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderCheckoutPage from 'components/Order/OrderCheckoutPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; orderType: OrderType; - dehydratedState?: DehydratedState; } // order처럼 DELIVERY가 아니면 포장 주문 화면으로 그린다 @@ -37,49 +36,49 @@ function OrderCheckoutRoute({ isDesktop, orderType }: Props) { } // 결제(주문서)는 사용자별 화면이라 공개 캐시를 켜지 않는다 -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const orderType = parseOrderType(context.search.orderType); +const loadPage = definePageLoad(async ({ search, queryClient }, _cacheControl, serverRequest) => { + const orderType = parseOrderType(search.orderType); - if (serverRequest.device !== 'mobile') { - return { isDesktop: true, orderType }; - } + if (serverRequest.device !== 'mobile') { + return { isDesktop: true, orderType }; + } - // 로그인이면 order가 부르는 장바구니·학생 정보·배달기사님 요청 문구와, 장바구니의 상점으로 - // 가게 정보·입금 계좌와 배달 가능 여부(배달)를 서버에서 받아 본문까지 렌더한다. - // 실패한 조회는 결과를 내리지 않고 클라이언트가 다시 조회한다 - const queryClient = new QueryClient(); - if (serverRequest.isLoggedIn) { - const [cart] = await Promise.all([ - queryClient - .fetchQuery({ ...orderQueries.cart(orderType, true), queryFn: () => getCart(orderType, true) }) - .catch((): CartResponse | null => null), - queryClient.prefetchQuery(orderQueries.studentInfo(true)), - queryClient.prefetchQuery(orderQueries.riderMessages(true)), - ]); + // 로그인이면 order가 부르는 장바구니·학생 정보·배달기사님 요청 문구와, 장바구니의 상점으로 + // 가게 정보·입금 계좌와 배달 가능 여부(배달)를 미리 받아 본문까지 렌더한다. + // 실패한 조회는 결과를 내리지 않고 클라이언트가 다시 조회한다 + if (serverRequest.isLoggedIn) { + const [cart] = await Promise.all([ + queryClient + .fetchQuery({ ...orderQueries.cart(orderType, true), queryFn: () => getCart(orderType, true) }) + .catch((): CartResponse | null => null), + queryClient.prefetchQuery(orderQueries.studentInfo(true)), + queryClient.prefetchQuery(orderQueries.riderMessages(true)), + ]); - // 장바구니는 orderable_shop_id만 주는데 입금 계좌는 shop_id로 조회하는 상점 상세에 있어, - // 주문 상점 상세에서 shop_id를 먼저 받은 뒤 상점 상세를 받는다 - const orderableShopId = cart?.orderable_shop_id ?? 0; - if (orderableShopId > 0) { - const [orderShop] = await Promise.all([ - queryClient.fetchQuery(storeMobileQueries.orderDetail(String(orderableShopId))).catch(() => null), - orderType === 'DELIVERY' && queryClient.prefetchQuery(orderQueries.shopDeliveryInfo(orderableShopId)), - ]); - if (orderShop) await queryClient.prefetchQuery(storeMobileQueries.detail(String(orderShop.shop_id))); - } - } + // 장바구니는 orderable_shop_id만 주는데 입금 계좌는 shop_id로 조회하는 상점 상세에 있어, + // 주문 상점 상세에서 shop_id를 먼저 받은 뒤 상점 상세를 받는다 + const orderableShopId = cart?.orderable_shop_id ?? 0; + if (orderableShopId > 0) { + const [orderShop] = await Promise.all([ + queryClient.fetchQuery(storeMobileQueries.orderDetail(String(orderableShopId))).catch(() => null), + orderType === 'DELIVERY' && queryClient.prefetchQuery(orderQueries.shopDeliveryInfo(orderableShopId)), + ]); + if (orderShop) await queryClient.prefetchQuery(storeMobileQueries.detail(String(orderShop.shop_id))); + } + } - return { isDesktop: false, orderType, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { isDesktop: false, orderType }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/checkout/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.delivery.campus.index.tsx b/apps/web/src/routes/order.delivery.campus.index.tsx index 30d1029f..9ac358f9 100644 --- a/apps/web/src/routes/order.delivery.campus.index.tsx +++ b/apps/web/src/routes/order.delivery.campus.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, type DehydratedState, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -10,10 +9,10 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderDeliveryCampusPage from 'components/Order/OrderDelivery/OrderDeliveryCampusPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; - dehydratedState?: DehydratedState; } function OrderDeliveryCampusRoute({ isDesktop }: Props) { @@ -27,31 +26,29 @@ function OrderDeliveryCampusRoute({ isDesktop }: Props) { } // 배달지 선택은 사용자별 화면이라 공개 캐시를 켜지 않는다 +const loadPage = definePageLoad(async ({ queryClient }, _cacheControl, serverRequest) => { + if (serverRequest.device !== 'mobile') { + return { isDesktop: true }; + } + + // order가 부르는 교내 배달지 분류 3개를 미리 받아 본문까지 렌더한다. + // 실패하면 결과를 내리지 않고 클라이언트가 다시 조회한다 + await Promise.all( + CAMPUS_ADDRESS_CATEGORIES.map((filter) => queryClient.prefetchQuery(orderQueries.campusDeliveryAddresses(filter))), + ); + + return { isDesktop: false }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, _cacheControl, serverRequest) => { - if (serverRequest.device !== 'mobile') { - return { isDesktop: true }; - } - - // order가 부르는 교내 배달지 분류 3개를 서버에서 받아 본문까지 렌더한다. - // 실패하면 결과를 내리지 않고 클라이언트가 다시 조회한다 - const queryClient = new QueryClient(); - await Promise.all( - CAMPUS_ADDRESS_CATEGORIES.map((filter) => - queryClient.prefetchQuery(orderQueries.campusDeliveryAddresses(filter)), - ), - ); - - return { isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/delivery/campus/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.delivery.outside.detail.index.tsx b/apps/web/src/routes/order.delivery.outside.detail.index.tsx index c2f00869..c683f6d3 100644 --- a/apps/web/src/routes/order.delivery.outside.detail.index.tsx +++ b/apps/web/src/routes/order.delivery.outside.detail.index.tsx @@ -7,6 +7,7 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderDeliveryOutsideDetailPage from 'components/Order/OrderDelivery/OrderDeliveryOutsideDetailPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; @@ -24,24 +25,25 @@ function OrderDeliveryOutsideDetailRoute({ isDesktop, roadAddress }: Props) { } // 배달지 선택은 사용자별 화면이라 공개 캐시를 켜지 않는다. -// order는 도로명 주소를 location.state로 넘긴다. 여기서는 쿼리(roadAddress)로 받아 서버가 주소까지 렌더한다(규칙 10) +// order는 도로명 주소를 location.state로 넘긴다. 여기서는 쿼리(roadAddress)로 받아 SSR에서도 주소까지 렌더한다. +const loadPage = definePageLoad(({ search }, _cacheControl, serverRequest) => { + const { roadAddress } = search; + + return { + isDesktop: serverRequest.device !== 'mobile', + roadAddress: typeof roadAddress === 'string' ? roadAddress : '', + }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, (context, _cacheControl, serverRequest) => { - const { roadAddress } = context.search; - - return { - isDesktop: serverRequest.device !== 'mobile', - roadAddress: typeof roadAddress === 'string' ? roadAddress : '', - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/delivery/outside/detail/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.delivery.outside.index.tsx b/apps/web/src/routes/order.delivery.outside.index.tsx index d304477a..b1e634a6 100644 --- a/apps/web/src/routes/order.delivery.outside.index.tsx +++ b/apps/web/src/routes/order.delivery.outside.index.tsx @@ -7,6 +7,7 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderDeliveryOutsidePage from 'components/Order/OrderDelivery/OrderDeliveryOutsidePage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; @@ -23,18 +24,19 @@ function OrderDeliveryOutsideRoute({ isDesktop }: Props) { } // 배달지 선택은 사용자별 화면이라 공개 캐시를 켜지 않는다. 첫 화면은 검색 안내만 있어 받아 둘 데이터가 없다 +const loadPage = definePageLoad((_context, _cacheControl, serverRequest) => ({ + isDesktop: serverRequest.device !== 'mobile', +})); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, (_context, _cacheControl, serverRequest) => ({ - isDesktop: serverRequest.device !== 'mobile', - })), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/delivery/outside/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.index.tsx b/apps/web/src/routes/order.index.tsx index 82297882..e86eec8e 100644 --- a/apps/web/src/routes/order.index.tsx +++ b/apps/web/src/routes/order.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, type DehydratedState, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -10,11 +9,11 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderHomePage from 'components/Order/OrderHomePage'; import { DEFAULT_ORDER_HOME_CONDITIONS, getOrderableShopsParams } from 'components/Order/OrderHomePage/constants'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; - dehydratedState?: DehydratedState; } function OrderHomeRoute({ isDesktop }: Props) { @@ -37,32 +36,32 @@ function OrderHomeRoute({ isDesktop }: Props) { } // 주문 홈은 공개 화면이라 비로그인이면 다른 상점 목록과 같은 공개 캐시를 켠다(로그인이면 loadPageData가 무시한다) -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl, serverRequest) => { - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); +const loadPage = definePageLoad(async ({ queryClient }, cacheControl, serverRequest) => { + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - if (serverRequest.device !== 'mobile') { - return { isDesktop: true }; - } + if (serverRequest.device !== 'mobile') { + return { isDesktop: true }; + } - // order가 처음 부르는 두 요청(카테고리·기본 조건 상점 목록)을 서버에서 받아 본문까지 렌더한다. - // 실패하면 결과를 내리지 않고 클라이언트가 다시 조회한다 - const queryClient = new QueryClient(); - await Promise.all([ - queryClient.prefetchQuery(storeMobileQueries.categories()), - queryClient.prefetchQuery(orderQueries.orderableShops(getOrderableShopsParams(DEFAULT_ORDER_HOME_CONDITIONS))), - ]); + // order가 처음 부르는 두 요청(카테고리·기본 조건 상점 목록)을 미리 받아 본문까지 렌더한다. + // 실패하면 결과를 내리지 않고 클라이언트가 다시 조회한다 + await Promise.all([ + queryClient.prefetchQuery(storeMobileQueries.categories()), + queryClient.prefetchQuery(orderQueries.orderableShops(getOrderableShopsParams(DEFAULT_ORDER_HOME_CONDITIONS))), + ]); - return { isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { isDesktop: false }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.orders.index.tsx b/apps/web/src/routes/order.orders.index.tsx index e4cf664a..175ff771 100644 --- a/apps/web/src/routes/order.orders.index.tsx +++ b/apps/web/src/routes/order.orders.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, type DehydratedState, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -10,10 +9,10 @@ import OrderListPage from 'components/Order/OrderListPage'; import { DEFAULT_ORDER_HISTORY_FILTER } from 'components/Order/OrderListPage/constants'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; interface Props { isDesktop: boolean; - dehydratedState?: DehydratedState; } function OrderListRoute({ isDesktop }: Props) { @@ -36,32 +35,32 @@ function OrderListRoute({ isDesktop }: Props) { } // 주문 내역은 사용자별 화면이라 공개 캐시를 켜지 않는다 -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, _cacheControl, serverRequest) => { - if (serverRequest.device !== 'mobile') { - return { isDesktop: true }; - } +const loadPage = definePageLoad(async ({ queryClient }, _cacheControl, serverRequest) => { + if (serverRequest.device !== 'mobile') { + return { isDesktop: true }; + } - // 로그인이면 order가 처음 부르는 두 요청(지난 주문 첫 페이지·준비 중 주문)을 서버에서 받아 본문까지 렌더한다. - // 실패하면 결과를 내리지 않고 클라이언트가 다시 조회한다 - const queryClient = new QueryClient(); - if (serverRequest.isLoggedIn) { - await Promise.all([ - queryClient.prefetchInfiniteQuery(orderQueries.orderHistory(DEFAULT_ORDER_HISTORY_FILTER, true)), - queryClient.prefetchQuery(orderQueries.inProgressOrders(true)), - ]); - } + // 로그인이면 order가 처음 부르는 두 요청(지난 주문 첫 페이지·준비 중 주문)을 미리 받아 본문까지 렌더한다. + // 실패하면 결과를 내리지 않고 클라이언트가 다시 조회한다 + if (serverRequest.isLoggedIn) { + await Promise.all([ + queryClient.prefetchInfiniteQuery(orderQueries.orderHistory(DEFAULT_ORDER_HISTORY_FILTER, true)), + queryClient.prefetchQuery(orderQueries.inProgressOrders(true)), + ]); + } - return { isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { isDesktop: false }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/orders/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.result.$paymentId.index.tsx b/apps/web/src/routes/order.result.$paymentId.index.tsx index 5044588b..7e317e53 100644 --- a/apps/web/src/routes/order.result.$paymentId.index.tsx +++ b/apps/web/src/routes/order.result.$paymentId.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -10,6 +9,7 @@ import DesktopNotice from 'components/Order/DesktopNotice'; import OrderResultPage from 'components/Order/OrderResultPage'; import OrderResultHeader from 'components/Order/OrderResultPage/components/OrderResultHeader'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; // useNaverMapsLoaded가 붙이는 지도 스크립트와 같은 주소(미리 받기용) @@ -49,36 +49,36 @@ function OrderResultRoute({ isDesktop, paymentId }: Props) { } // 주문 결과는 사용자별 화면이라 공개 캐시를 켜지 않는다 -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const paymentId = parsePaymentId(context.params.paymentId); - if (!paymentId) throw notFound(); +const loadPage = definePageLoad(async ({ params, queryClient }, _cacheControl, serverRequest) => { + const paymentId = parsePaymentId(params.paymentId); + if (!paymentId) throw notFound(); - if (serverRequest.device !== 'mobile') { - return { isDesktop: true, paymentId }; - } + if (serverRequest.device !== 'mobile') { + return { isDesktop: true, paymentId }; + } - // 로그인이면 order가 부르는 결제 정보만 서버에서 받아 본문까지 렌더한다. - // 없는 주문(404)은 notFound로 응답하고, 그 밖의 실패는 결과를 내리지 않고 클라이언트가 다시 조회한다 - const queryClient = new QueryClient(); - if (serverRequest.isLoggedIn) { - try { - await queryClient.fetchQuery(orderQueries.paymentInfo(paymentId, true)); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - } - } + // 로그인이면 order가 부르는 결제 정보만 미리 받아 본문까지 렌더한다. + // 없는 주문(404)은 notFound로 응답하고, 그 밖의 실패는 결과를 내리지 않고 클라이언트가 다시 조회한다 + if (serverRequest.isLoggedIn) { + try { + await queryClient.fetchQuery(orderQueries.paymentInfo(paymentId, true)); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + } + } - return { isDesktop: false, paymentId, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { isDesktop: false, paymentId }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/result/$paymentId/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.shops.$id.index.tsx b/apps/web/src/routes/order.shops.$id.index.tsx index 486c818f..df33e4c4 100644 --- a/apps/web/src/routes/order.shops.$id.index.tsx +++ b/apps/web/src/routes/order.shops.$id.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -9,7 +8,8 @@ import OrderViewport from 'components/Order/common/OrderViewport'; import DesktopNotice from 'components/Order/DesktopNotice'; import OrderShopPage from 'components/Order/OrderShopPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -36,59 +36,59 @@ function OrderShopRoute({ id, isDesktop }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); - if (serverRequest.device !== 'mobile') { - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + if (serverRequest.device !== 'mobile') { + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { id, isDesktop: true }; - } + return { id, isDesktop: true }; + } - // 요약·메뉴 그룹·메뉴·상세(전화번호), 요약의 일반 상점 번호로 이벤트, 장바구니를 서버에서 받아 본문까지 렌더한다 - const queryClient = new QueryClient(); - try { - const [summary] = await Promise.all([ - queryClient.fetchQuery(storeMobileQueries.orderSummary(id)), - queryClient.fetchQuery(storeMobileQueries.orderMenuGroups(id)), - queryClient.fetchQuery(storeMobileQueries.orderMenus(id)), - queryClient.fetchQuery(storeMobileQueries.orderDetail(id)), - ]); - await queryClient.fetchQuery(storeMobileQueries.events(String(summary.shop_id))); - } catch (error) { - if (isNotFoundKoinError(error)) { - throw notFound(); - } - throw error; - } + // 요약·메뉴 그룹·메뉴·상세(전화번호), 요약의 일반 상점 번호로 이벤트를 미리 받아 본문까지 렌더한다 + try { + const [summary] = await Promise.all([ + queryClient.fetchQuery(storeMobileQueries.orderSummary(id)), + queryClient.fetchQuery(storeMobileQueries.orderMenuGroups(id)), + queryClient.fetchQuery(storeMobileQueries.orderMenus(id)), + queryClient.fetchQuery(storeMobileQueries.orderDetail(id)), + ]); + await queryClient.fetchQuery(storeMobileQueries.events(String(summary.shop_id))); + } catch (error) { + if (isNotFoundKoinError(error)) { + throw notFound(); + } + throw error; + } - // 로그인이면 장바구니(주문 유형 기본값 DELIVERY)까지 받아 하단 장바구니 막대를 서버에서 그린다. - // 막대 조회 실패는 본문 렌더를 막지 않는다(클라이언트가 다시 조회). 비로그인은 공유 캐시를 쓰고 장바구니는 클라이언트가 조회한다 - if (serverRequest.isLoggedIn) { - try { - const cart = await queryClient.fetchQuery(orderQueries.cart('DELIVERY', true)); - if (cart.items.length > 0 && cart.orderable_shop_id === Number(id)) { - await queryClient.fetchQuery(orderQueries.cartSummary(id, true)); - } - } catch { - // 장바구니 막대만 빠진다 - } - } else { - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + // 로그인이면 장바구니(주문 유형 기본값 DELIVERY)까지 받아 하단 장바구니 막대를 그린다. + // 막대 조회 실패는 본문 렌더를 막지 않는다(화면이 다시 조회). 비로그인은 공유 캐시를 쓰고 장바구니는 화면이 조회한다 + if (serverRequest.isLoggedIn) { + try { + const cart = await queryClient.fetchQuery(orderQueries.cart('DELIVERY', true)); + if (cart.items.length > 0 && cart.orderable_shop_id === Number(id)) { + await queryClient.fetchQuery(orderQueries.cartSummary(id, true)); } + } catch { + // 장바구니 막대만 빠진다 + } + } else { + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + } - return { id, isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { id, isDesktop: false }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/shops/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.shops.$id.info.index.tsx b/apps/web/src/routes/order.shops.$id.info.index.tsx index d982b622..417359ea 100644 --- a/apps/web/src/routes/order.shops.$id.info.index.tsx +++ b/apps/web/src/routes/order.shops.$id.info.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -8,7 +7,8 @@ import OrderViewport from 'components/Order/common/OrderViewport'; import DesktopNotice from 'components/Order/DesktopNotice'; import OrderShopInfoPage from 'components/Order/OrderShopInfoPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -26,36 +26,36 @@ function OrderShopInfoRoute({ id, isDesktop }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - if (serverRequest.device !== 'mobile') { - return { id, isDesktop: true }; - } + if (serverRequest.device !== 'mobile') { + return { id, isDesktop: true }; + } - // order가 부르는 상점 상세(order/shop/{id}/detail)만 서버에서 받아 본문까지 렌더한다 - const queryClient = new QueryClient(); - try { - await queryClient.fetchQuery(storeMobileQueries.orderDetail(id)); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } + // order가 부르는 상점 상세(order/shop/{id}/detail)만 미리 받아 본문까지 렌더한다 + try { + await queryClient.fetchQuery(storeMobileQueries.orderDetail(id)); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } - return { id, isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { id, isDesktop: false }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/shops/$id/info/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.shops.$id.menus.$menuId.index.tsx b/apps/web/src/routes/order.shops.$id.menus.$menuId.index.tsx index 32f0403c..89b65c04 100644 --- a/apps/web/src/routes/order.shops.$id.menus.$menuId.index.tsx +++ b/apps/web/src/routes/order.shops.$id.menus.$menuId.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -8,7 +7,8 @@ import OrderViewport from 'components/Order/common/OrderViewport'; import DesktopNotice from 'components/Order/DesktopNotice'; import OrderMenuDetailPage from 'components/Order/OrderMenuDetailPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -36,43 +36,43 @@ function OrderMenuDetailRoute({ id, menuId, isDesktop }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const { id, menuId } = context.params; - if (!id || !menuId) throw notFound(); +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const { id, menuId } = params; + if (!id || !menuId) throw notFound(); - if (serverRequest.device !== 'mobile') { - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + if (serverRequest.device !== 'mobile') { + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { id, menuId, isDesktop: true }; - } + return { id, menuId, isDesktop: true }; + } + + // order가 부르는 메뉴 상세만 미리 받아 본문까지 렌더한다(order는 이 화면에서 장바구니를 조회하지 않는다). + // 편집 모드(?editCartItemId)의 장바구니 항목 옵션은 화면이 조회한다 + try { + await queryClient.fetchQuery(orderQueries.menuDetail(id, menuId)); + } catch (error) { + if (isNotFoundKoinError(error)) { + throw notFound(); + } + throw error; + } - // order가 부르는 메뉴 상세만 서버에서 받아 본문까지 렌더한다(order는 이 화면에서 장바구니를 조회하지 않는다). - // 편집 모드(?editCartItemId)의 장바구니 항목 옵션은 클라이언트가 조회한다 - const queryClient = new QueryClient(); - try { - await queryClient.fetchQuery(orderQueries.menuDetail(id, menuId)); - } catch (error) { - if (isNotFoundKoinError(error)) { - throw notFound(); - } - throw error; - } + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + } - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - } + return { id, menuId, isDesktop: false }; +}); - return { id, menuId, isDesktop: false, dehydratedState: dehydrate(queryClient) }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/shops/$id/menus/$menuId/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/order.shops.$id.reviews.index.tsx b/apps/web/src/routes/order.shops.$id.reviews.index.tsx index f985ee3b..c6086f41 100644 --- a/apps/web/src/routes/order.shops.$id.reviews.index.tsx +++ b/apps/web/src/routes/order.shops.$id.reviews.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -10,7 +9,8 @@ import OrderShopReviewsPage from 'components/Order/OrderShopReviewsPage'; import StoreMobileHeader from 'components/Store/mobile/common/StoreMobileHeader'; import { storeReviewsQueries } from 'components/Store/mobile/StoreReviewsPage/queries'; import { parseReviewSort } from 'components/Store/mobile/StoreReviewsPage/utils/reviewSort'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; type Props = { isDesktop: true; shopId?: undefined } | { isDesktop: false; shopId: string }; @@ -25,52 +25,51 @@ function OrderShopReviewsRoute({ isDesktop, shopId }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); +const loadPage = definePageLoad(async ({ params, search, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); - if (serverRequest.device !== 'mobile') { - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + if (serverRequest.device !== 'mobile') { + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { isDesktop: true as const }; - } + return { isDesktop: true as const }; + } - // URL의 id는 주문 가능 상점 번호(orderable_shop_id)다. order는 이 값을 그대로 리뷰 조회에 넘겨 - // 다른 상점 리뷰가 나오는 버그가 있어, 요약의 일반 상점 번호(shop_id)로 리뷰·상점 정보를 조회한다 - const { isLoggedIn } = serverRequest; - const sort = parseReviewSort(context.search.sort); - const queryClient = new QueryClient(); - let shopId: string; - try { - const summary = await queryClient.fetchQuery(storeMobileQueries.orderSummary(id)); - shopId = String(summary.shop_id); + // URL의 id는 주문 가능 상점 번호(orderable_shop_id)다. 요약의 일반 상점 번호(shop_id)로 리뷰·상점 정보를 조회한다 + const { isLoggedIn } = serverRequest; + const sort = parseReviewSort(search.sort); + let shopId: string; + try { + const summary = await queryClient.fetchQuery(storeMobileQueries.orderSummary(id)); + shopId = String(summary.shop_id); - // 상점명·리뷰 통계(최신순)·선택한 정렬의 목록을 서버에서 받아 본문까지 렌더한다(상점 리뷰 화면과 같은 구성). - // 리뷰 요청은 만료된 access 쿠키로 실패할 수 있어 실패해도 페이지는 그리고 클라이언트가 다시 받는다 - await Promise.all([ - queryClient.fetchQuery(storeMobileQueries.detail(shopId)), - queryClient.prefetchQuery(storeMobileQueries.reviewList(shopId, 'LATEST', isLoggedIn)), - sort !== 'LATEST' ? queryClient.prefetchQuery(storeMobileQueries.reviewList(shopId, sort, isLoggedIn)) : null, - isLoggedIn ? queryClient.prefetchQuery(storeReviewsQueries.myReviews(shopId, sort, isLoggedIn)) : null, - ]); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } + // 상점명·리뷰 통계(최신순)·선택한 정렬의 목록을 미리 받아 본문까지 렌더한다(상점 리뷰 화면과 같은 구성). + // 리뷰 요청은 만료된 access 쿠키로 실패할 수 있어 실패해도 페이지는 그리고 화면이 다시 받는다 + await Promise.all([ + queryClient.fetchQuery(storeMobileQueries.detail(shopId)), + queryClient.prefetchQuery(storeMobileQueries.reviewList(shopId, 'LATEST', isLoggedIn)), + sort !== 'LATEST' ? queryClient.prefetchQuery(storeMobileQueries.reviewList(shopId, sort, isLoggedIn)) : null, + isLoggedIn ? queryClient.prefetchQuery(storeReviewsQueries.myReviews(shopId, sort, isLoggedIn)) : null, + ]); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } - if (!isLoggedIn) cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + if (!isLoggedIn) cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { isDesktop: false as const, shopId, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { isDesktop: false as const, shopId }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/order/shops/$id/reviews/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/profile.index.tsx b/apps/web/src/routes/profile.index.tsx index 6c9b8e83..23d81e69 100644 --- a/apps/web/src/routes/profile.index.tsx +++ b/apps/web/src/routes/profile.index.tsx @@ -1,9 +1,7 @@ import type { ComponentType, SVGProps } from 'react'; import { Suspense } from 'react'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, Link } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; -import { getCookie } from '@tanstack/react-start/server'; import { authQueries } from 'api/auth/queries'; import type { Semester } from 'api/timetable/entity'; @@ -24,6 +22,7 @@ import { useLogout } from 'utils/hooks/auth/useLogout'; import useBooleanState from 'utils/hooks/state/useBooleanState'; import { useUser } from 'utils/hooks/state/useUser'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { getRecentSemester, isSemesterInList } from 'utils/timetable/semester'; import { parseSemesterCookie } from 'utils/timetable/semesterCookie'; import { isUnauthorizedError } from 'utils/ts/httpError'; @@ -236,100 +235,96 @@ function ProfilePage({ serverSemester, hasSemesterCookie }: Awaited - loadPageData(data, async (_context, cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - const { isLoggedIn, userType } = serverRequest; - - if (!isLoggedIn) { - // 공용 캐시에 저장되는 응답이므로 학기 쿠키 같은 방문자별 값은 읽지 않는다. 비로그인은 시간표를 그리지 않는다. - queryClient.setQueryData(authQueries.userInfo(false).queryKey, null); - cacheControl.enablePublicCache(); - - return { - serverSemester: getRecentSemester(), - hasSemesterCookie: false, - dehydratedState: dehydrate(queryClient), - }; +// 로그인 여부·사용자 정보·시간표를 먼저 확정해 프로필을 그린다. +const loadPage = definePageLoad(async ({ queryClient, getCookie }, cacheControl, serverRequest) => { + const { isLoggedIn, userType } = serverRequest; + const recentSemester = getRecentSemester(new Date(serverRequest.now)); + + if (!isLoggedIn) { + // 공용 캐시에 저장되는 응답이므로 학기 쿠키 같은 방문자별 값은 읽지 않는다. 비로그인은 시간표를 그리지 않는다. + queryClient.setQueryData(authQueries.userInfo(false).queryKey, null); + cacheControl.enablePublicCache(); + + return { + serverSemester: recentSemester, + hasSemesterCookie: false, + }; + } + + // 사용자가 시간표에서 고른 학기. 스토어(localStorage)가 원본이고 쿠키는 서버용 사본이다. + const cookieSemester = parseSemesterCookie(getCookie(TIMETABLE_SEMESTER_COOKIE_KEY)); + let serverSemester: Semester = cookieSemester ?? recentSemester; + + const setDefaultTimetableFrameList = () => { + queryClient.setQueryData( + timetableQueryKeys.frameList(serverSemester, isLoggedIn), + createDefaultTimetableFrameList(), + ); + }; + + const prefetchTimetable = async () => { + try { + // 학생이 아니면 클라이언트 훅도 내 학기 조회 없이 null을 쓴다. + if (userType !== 'STUDENT') queryClient.setQueryData(timetableQueryKeys.mySemester(isLoggedIn), null); + const [allSemesters, mySemester] = await Promise.all([ + queryClient.fetchQuery(timetableQueries.semesterInfo()), + userType === 'STUDENT' ? queryClient.fetchQuery(timetableQueries.mySemester(isLoggedIn, { userType })) : null, + ]); + + // useResetInvalidSemester와 같은 규칙: 고른 학기가 선택 가능한 목록에 없으면 목록의 첫 학기로 되돌린다. + const semesterList = mySemester?.semesters ?? allSemesters ?? []; + if ( + semesterList.length > 0 && + !isSemesterInList( + semesterList.map((value) => ({ value })), + serverSemester, + ) + ) { + [serverSemester] = semesterList; } - // 사용자가 시간표에서 고른 학기. 스토어(localStorage)가 원본이고 쿠키는 서버용 사본이다. - const cookieSemester = parseSemesterCookie(getCookie(TIMETABLE_SEMESTER_COOKIE_KEY)); - let serverSemester: Semester = cookieSemester ?? getRecentSemester(); - - const setDefaultTimetableFrameList = () => { - queryClient.setQueryData( - timetableQueryKeys.frameList(serverSemester, isLoggedIn), - createDefaultTimetableFrameList(), - ); - }; - - const prefetchTimetable = async () => { - try { - // 학생이 아니면 클라이언트 훅도 내 학기 조회 없이 null을 쓴다. - if (userType !== 'STUDENT') queryClient.setQueryData(timetableQueryKeys.mySemester(isLoggedIn), null); - const [allSemesters, mySemester] = await Promise.all([ - queryClient.fetchQuery(timetableQueries.semesterInfo()), - userType === 'STUDENT' - ? queryClient.fetchQuery(timetableQueries.mySemester(isLoggedIn, { userType })) - : null, - ]); - - // useResetInvalidSemester와 같은 규칙: 고른 학기가 선택 가능한 목록에 없으면 목록의 첫 학기로 되돌린다. - const semesterList = mySemester?.semesters ?? allSemesters ?? []; - if ( - semesterList.length > 0 && - !isSemesterInList( - semesterList.map((value) => ({ value })), - serverSemester, - ) - ) { - [serverSemester] = semesterList; - } - - if (userType !== 'STUDENT' || !mySemester?.semesters.length) { - setDefaultTimetableFrameList(); - - return; - } - - const frameList = await queryClient.fetchQuery( - timetableQueries.frameList(isLoggedIn, serverSemester, { userType }), - ); - const mainFrameId = frameList.find((frame) => frame.is_main)?.id; - if (typeof mainFrameId === 'number') { - await queryClient.prefetchQuery(timetableQueries.lectureInfo(isLoggedIn, mainFrameId)); - } - } catch (error) { - if (!isUnauthorizedError(error) && !(isKoinError(error) && (error.status === 403 || error.status === 404))) { - throw error; - } - // 시간표를 못 불러와도 프로필 자체는 보여준다. 빈 그리드로 확정한다. - if (queryClient.getQueryData(timetableQueryKeys.mySemester(isLoggedIn)) === undefined) { - queryClient.setQueryData(timetableQueryKeys.mySemester(isLoggedIn), null); - } - setDefaultTimetableFrameList(); - } - }; + if (userType !== 'STUDENT' || !mySemester?.semesters.length) { + setDefaultTimetableFrameList(); - await Promise.all([queryClient.prefetchQuery(authQueries.userInfo(true)), prefetchTimetable()]); + return; + } - return { - serverSemester, - hasSemesterCookie: cookieSemester !== null, - dehydratedState: dehydrate(queryClient), - }; - }), - ); + const frameList = await queryClient.fetchQuery( + timetableQueries.frameList(isLoggedIn, serverSemester, { userType }), + ); + const mainFrameId = frameList.find((frame) => frame.is_main)?.id; + if (typeof mainFrameId === 'number') { + await queryClient.prefetchQuery(timetableQueries.lectureInfo(isLoggedIn, mainFrameId)); + } + } catch (error) { + if (!isUnauthorizedError(error) && !(isKoinError(error) && (error.status === 403 || error.status === 404))) { + throw error; + } + // 시간표를 못 불러와도 프로필 자체는 보여준다. 빈 그리드로 확정한다. + if (queryClient.getQueryData(timetableQueryKeys.mySemester(isLoggedIn)) === undefined) { + queryClient.setQueryData(timetableQueryKeys.mySemester(isLoggedIn), null); + } + setDefaultTimetableFrameList(); + } + }; + + await Promise.all([queryClient.prefetchQuery(authQueries.userInfo(true)), prefetchTimetable()]); + + return { + serverSemester, + hasSemesterCookie: cookieSemester !== null, + }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/profile/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/report.review.shopid.$id.reviewid.$reviewid.index.tsx b/apps/web/src/routes/report.review.shopid.$id.reviewid.$reviewid.index.tsx index 8ad4bd4f..b073d306 100644 --- a/apps/web/src/routes/report.review.shopid.$id.reviewid.$reviewid.index.tsx +++ b/apps/web/src/routes/report.review.shopid.$id.reviewid.$reviewid.index.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react'; import { cn } from '@bcsdlab/utils'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch, useParams, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -15,6 +14,7 @@ import ROUTES from 'static/routes'; import useLogger from 'utils/hooks/analytics/useLogger'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import showToast from 'utils/ts/showToast'; import styles from 'pages/report/review/shopid/[id]/reviewid/[reviewid]/ReviewReporting.module.scss'; @@ -184,30 +184,30 @@ function ReviewReportRoute({ id, reviewId, isMobile }: Props) { } // 로그인 필수 화면이라 공용 캐시를 켜지 않는다(loadPageData 기본값 private) +const loadPage = definePageLoad(async ({ params, queryClient }, _cacheControl, serverRequest) => { + const { id, reviewid: reviewId } = params; + if (!id || !reviewId) throw notFound(); + + // 데스크톱은 기존처럼 브라우저에서 그린다 + if (serverRequest.device !== 'mobile') { + return { id, reviewId, isMobile: false }; + } + + // 모바일은 KOIN_ORDER_WEBVIEW처럼 신고 카테고리만 미리 받아 본문까지 렌더한다 + await queryClient.fetchQuery(storeMobileQueries.reportCategories()); + + return { id, reviewId, isMobile: true }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const { id, reviewid: reviewId } = context.params; - if (!id || !reviewId) throw notFound(); - - // 데스크톱은 기존처럼 클라이언트에서 그린다 - if (serverRequest.device !== 'mobile') { - return { id, reviewId, isMobile: false }; - } - - // 모바일은 KOIN_ORDER_WEBVIEW처럼 신고 카테고리만 받아 본문까지 렌더한다(클라이언트는 같은 쿼리 키로 캐시를 이어받는다) - const queryClient = new QueryClient(); - await queryClient.fetchQuery(storeMobileQueries.reportCategories()); - - return { id, reviewId, isMobile: true, dehydratedState: dehydrate(queryClient) }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/report/review/shopid/$id/reviewid/$reviewid/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { requireAuth: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/room.$id.index.tsx b/apps/web/src/routes/room.$id.index.tsx index 4b050265..77137ee6 100644 --- a/apps/web/src/routes/room.$id.index.tsx +++ b/apps/web/src/routes/room.$id.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -12,6 +11,7 @@ import useRoomDetail from 'components/Room/RoomDetailPage/hooks/useRoomDetail'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError, ROOM_ISR_REVALIDATE_SECONDS, withStaticFetchRetry } from 'utils/ts/isr'; import styles from 'pages/room/[id]/RoomDetailPage.module.scss'; @@ -65,40 +65,37 @@ function RoomDetailPage({ id }: RoomDetailPageProps) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async ({ params }, cacheControl) => { - const { id } = params; - if (!id) { - cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); - throw notFound(); - } - const queryClient = new QueryClient(); - - try { - await withStaticFetchRetry('room.detail', () => queryClient.fetchQuery(roomQueries.detail(id))); - } catch (error) { - if (isNotFoundKoinError(error)) { - cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); - throw notFound(); - } - throw error; - } +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl) => { + const { id } = params; + if (!id) { + cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); + throw notFound(); + } + try { + await withStaticFetchRetry('room.detail', () => queryClient.fetchQuery(roomQueries.detail(id))); + } catch (error) { + if (isNotFoundKoinError(error)) { cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); + throw notFound(); + } + throw error; + } - return { - dehydratedState: dehydrate(queryClient), - id, - }; - }), - ); + cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); + + return { id }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/room/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/room.index.tsx b/apps/web/src/routes/room.index.tsx index e5c253cb..028c3a90 100644 --- a/apps/web/src/routes/room.index.tsx +++ b/apps/web/src/routes/room.index.tsx @@ -1,4 +1,4 @@ -import { QueryClient, dehydrate, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -11,6 +11,7 @@ import useNaverMapScript from 'components/Room/RoomPage/hooks/useNaverMapScript' import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { ROOM_ISR_REVALIDATE_SECONDS, withStaticFetchRetry } from 'utils/ts/isr'; import styles from 'pages/room/RoomPage.module.scss'; @@ -36,26 +37,23 @@ function RoomPage() { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl) => { - const queryClient = new QueryClient(); +const loadPage = definePageLoad(async ({ queryClient }, cacheControl) => { + await withStaticFetchRetry('room.list', () => queryClient.fetchQuery(roomQueries.list())); - await withStaticFetchRetry('room.list', () => queryClient.fetchQuery(roomQueries.list())); + cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); - cacheControl.enablePublicCache(`public, s-maxage=${ROOM_ISR_REVALIDATE_SECONDS}, stale-while-revalidate=300`); + return {}; +}); - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/room/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/store.$id.events.index.tsx b/apps/web/src/routes/store.$id.events.index.tsx index 60fabb28..4fe3f2a0 100644 --- a/apps/web/src/routes/store.$id.events.index.tsx +++ b/apps/web/src/routes/store.$id.events.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -7,7 +6,8 @@ import { SSRLayout } from 'components/layout'; import MobilePageHeader from 'components/layout/MobilePageHeader'; import StoreEventsPage from 'components/Store/mobile/StoreEventsPage'; import ROUTES from 'static/routes'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -23,37 +23,37 @@ function StoreEventsRoute({ id }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); - // 모바일 전용 화면. 데스크톱은 서버가 확정한 기기 값으로 기존 화면에 보낸다 - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.StoreDetail({ id }), statusCode: 302 }); - } + // 모바일 전용 화면. 데스크톱은 확정된 기기 값으로 기존 화면에 보낸다 + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.StoreDetail({ id }), statusCode: 302 }); + } - // 이벤트 목록을 서버에서 받아 본문까지 렌더한다(클라이언트는 같은 쿼리 키로 캐시를 이어받는다) - const queryClient = new QueryClient(); - try { - await queryClient.fetchQuery(storeMobileQueries.events(id)); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } + // 이벤트 목록을 미리 받아 본문까지 렌더한다 + try { + await queryClient.fetchQuery(storeMobileQueries.events(id)); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { id, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { id }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/$id/events/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/store.$id.index.tsx b/apps/web/src/routes/store.$id.index.tsx index a71716d4..b8d99119 100644 --- a/apps/web/src/routes/store.$id.index.tsx +++ b/apps/web/src/routes/store.$id.index.tsx @@ -1,13 +1,6 @@ import React, { Suspense, useEffect, useRef } from 'react'; import { cn } from '@bcsdlab/utils'; -import { - dehydrate, - HydrationBoundary, - QueryClient, - useQueryClient, - useSuspenseQuery, - type DehydratedState, -} from '@tanstack/react-query'; +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -34,7 +27,8 @@ import useModalPortal from 'utils/hooks/layout/useModalPortal'; import useParamsHandler from 'utils/hooks/routing/useParamsHandler'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { copyToClipboard } from 'utils/ts/copyToClipboard'; import { isomorphicSessionStorage } from 'utils/ts/env'; import getDayOfWeek from 'utils/ts/getDayOfWeek'; @@ -53,7 +47,7 @@ interface Props { id: string; } -// 기기·로그인 여부를 서버가 알아야 모바일·데스크톱 렌더와 리뷰 쿼리 범위를 서버에서 확정할 수 있어 요청마다 SSR로 렌더한다. +// 기기·로그인 여부는 요청 스냅샷으로 확정한다. SSR은 요청별로 렌더하고, 브라우저 이동은 같은 쿼리 범위를 앱 캐시에 미리 받는다. // 요청을 모르는 정적 생성은 항상 guest 범위로 프리페치해 캐시 상태에 따라 요청이 달라진다 function StoreDetailPage({ id }: Props) { const isMobile = useMediaQuery(); @@ -455,12 +449,11 @@ function StoreDetailPage({ id }: Props) { } interface StoreDetailProps { - dehydratedState: DehydratedState; id: string; isMobilePage?: boolean; } -function StoreDetail({ dehydratedState, id, isMobilePage }: StoreDetailProps) { +function StoreDetail({ id, isMobilePage }: StoreDetailProps) { const routeNavigate = useNavigate(); if (isMobilePage) { @@ -469,92 +462,86 @@ function StoreDetail({ dehydratedState, id, isMobilePage }: StoreDetailProps) { return ( routeNavigate({ href: ROUTES.Store() })}> - - }> - - - + }> + + ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - const { isLoggedIn } = serverRequest; +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const { isLoggedIn } = serverRequest; - const storeId = context.params.id; - if (!storeId) { + const storeId = params.id; + if (!storeId) { + throw notFound(); + } + + // 모바일은 KOIN_ORDER_WEBVIEW 상점 상세 화면을 그린다. order가 부르는 API(요약·v2 상세·메뉴·이벤트)만 미리 받아 본문까지 렌더한다 + if (serverRequest.device === 'mobile') { + try { + await Promise.all([ + queryClient.fetchQuery(storeMobileQueries.summary(storeId)), + queryClient.fetchQuery(storeMobileQueries.detail(storeId)), + queryClient.fetchQuery(storeMobileQueries.menus(storeId)), + queryClient.fetchQuery(storeMobileQueries.events(storeId)), + ]); + } catch (error) { + if (isNotFoundKoinError(error)) { throw notFound(); } + throw error; + } - // 모바일은 KOIN_ORDER_WEBVIEW 상점 상세 화면을 그린다. order가 부르는 API(요약·v2 상세·메뉴·이벤트)만 서버에서 받아 본문까지 렌더한다 - if (serverRequest.device === 'mobile') { - const mobileQueryClient = new QueryClient(); - try { - await Promise.all([ - mobileQueryClient.fetchQuery(storeMobileQueries.summary(storeId)), - mobileQueryClient.fetchQuery(storeMobileQueries.detail(storeId)), - mobileQueryClient.fetchQuery(storeMobileQueries.menus(storeId)), - mobileQueryClient.fetchQuery(storeMobileQueries.events(storeId)), - ]); - } catch (error) { - if (isNotFoundKoinError(error)) { - throw notFound(); - } - throw error; - } + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + return { + isMobilePage: true, + id: storeId, + }; + } - return { - isMobilePage: true, - dehydratedState: dehydrate(mobileQueryClient), - id: storeId, - }; - } + try { + await Promise.all([ + queryClient.fetchQuery(storeQueries.detail(storeId)), + queryClient.fetchQuery(storeQueries.detailMenu(storeId)), + // 리뷰 요청에는 쿠키가 실린다. 만료·무효 access 쿠키로 실패해도 페이지는 그리고 갱신 후 다시 받는다(prefetchQuery는 오류를 삼킨다) + queryClient.prefetchQuery( + storeQueries.reviewList({ + shopId: Number(storeId), + page: 1, + sorter: 'LATEST', + isLoggedIn, + }), + ), + // 이벤트/공지 탭 데이터는 부가 정보이므로 실패해도 페이지 렌더를 막지 않도록 오류를 여기서 삼킨다 + queryClient.fetchQuery(storeQueries.eventList(storeId)).catch((error: unknown) => { + console.error(`Failed to prefetch optional store events for ${storeId}:`, error); + }), + ]); + } catch (error) { + if (isNotFoundKoinError(error)) { + throw notFound(); + } + throw error; + } - try { - await Promise.all([ - queryClient.fetchQuery(storeQueries.detail(storeId)), - queryClient.fetchQuery(storeQueries.detailMenu(storeId)), - // 리뷰는 요청 쿠키가 실린다. 만료·무효 access 쿠키면 API가 401을 주는데 서버는 refresh를 못 하므로 - // 실패해도 페이지는 그리고 클라이언트가 갱신 후 다시 받게 한다(prefetchQuery는 오류를 삼킨다) - queryClient.prefetchQuery( - storeQueries.reviewList({ - shopId: Number(storeId), - page: 1, - sorter: 'LATEST', - isLoggedIn, - }), - ), - // 이벤트/공지 탭 데이터는 부가 정보이므로 실패해도 페이지 렌더를 막지 않도록 오류를 여기서 삼킵니다. - queryClient.fetchQuery(storeQueries.eventList(storeId)).catch((error: unknown) => { - console.error(`[SSR] failed to prefetch optional store events for ${storeId}:`, error); - }), - ]); - } catch (error) { - if (isNotFoundKoinError(error)) { - throw notFound(); - } - throw error; - } + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + return { + id: storeId, + }; +}); - return { - dehydratedState: dehydrate(queryClient), - id: storeId, - }; - }), - ); +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/store.$id.info.index.tsx b/apps/web/src/routes/store.$id.info.index.tsx index ed8e6c04..1f9f0562 100644 --- a/apps/web/src/routes/store.$id.info.index.tsx +++ b/apps/web/src/routes/store.$id.info.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -7,7 +6,8 @@ import { SSRLayout } from 'components/layout'; import MobilePageHeader from 'components/layout/MobilePageHeader'; import StoreInfoPage from 'components/Store/mobile/StoreInfoPage'; import ROUTES from 'static/routes'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -23,37 +23,37 @@ function StoreInfoRoute({ id }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); +const loadPage = definePageLoad(async ({ params, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); - // 모바일 전용 화면. 데스크톱은 서버가 확정한 기기 값으로 기존 화면에 보낸다 - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.StoreDetail({ id }), statusCode: 302 }); - } + // 모바일 전용 화면. 데스크톱은 확정된 기기 값으로 기존 화면에 보낸다 + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.StoreDetail({ id }), statusCode: 302 }); + } - // 상점 정보를 서버에서 받아 본문까지 렌더한다(클라이언트는 같은 쿼리 키로 캐시를 이어받는다) - const queryClient = new QueryClient(); - try { - await queryClient.fetchQuery(storeMobileQueries.detail(id)); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } + // 상점 정보를 미리 받아 본문까지 렌더한다 + try { + await queryClient.fetchQuery(storeMobileQueries.detail(id)); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { id, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { id }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/$id/info/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/store.$id.reviews.index.tsx b/apps/web/src/routes/store.$id.reviews.index.tsx index b7acb4ba..46af00c5 100644 --- a/apps/web/src/routes/store.$id.reviews.index.tsx +++ b/apps/web/src/routes/store.$id.reviews.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, notFound, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -9,7 +8,8 @@ import StoreReviewsPage from 'components/Store/mobile/StoreReviewsPage'; import { storeReviewsQueries } from 'components/Store/mobile/StoreReviewsPage/queries'; import { parseReviewSort } from 'components/Store/mobile/StoreReviewsPage/utils/reviewSort'; import ROUTES from 'static/routes'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -25,46 +25,46 @@ function StoreReviewsRoute({ id }: Props) { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); +const loadPage = definePageLoad(async ({ params, search, queryClient }, cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); - // 모바일 전용 화면. 데스크톱은 서버가 확정한 기기 값으로 기존 화면에 보낸다 - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.StoreDetail({ id }), statusCode: 302 }); - } + // 모바일 전용 화면. 데스크톱은 확정된 기기 값으로 기존 화면에 보낸다 + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.StoreDetail({ id }), statusCode: 302 }); + } - // 상점명·리뷰 통계(최신순)·선택한 정렬의 목록을 서버에서 받아 본문까지 렌더한다. - // 로그인이면 내 리뷰도 받는다. 만료 등으로 실패해도 페이지는 그리고 클라이언트가 다시 받는다(prefetchQuery) - const { isLoggedIn } = serverRequest; - const sort = parseReviewSort(context.search.sort); - const queryClient = new QueryClient(); - try { - await Promise.all([ - queryClient.fetchQuery(storeMobileQueries.detail(id)), - // 리뷰 요청에는 요청 쿠키가 실린다. 만료·무효 access 쿠키면 401인데 서버는 refresh를 못 하므로 실패해도 넘어간다 - queryClient.prefetchQuery(storeMobileQueries.reviewList(id, 'LATEST', isLoggedIn)), - sort !== 'LATEST' ? queryClient.prefetchQuery(storeMobileQueries.reviewList(id, sort, isLoggedIn)) : null, - isLoggedIn ? queryClient.prefetchQuery(storeReviewsQueries.myReviews(id, sort, isLoggedIn)) : null, - ]); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } + // 상점명·리뷰 통계(최신순)·선택한 정렬의 목록을 미리 받아 본문까지 렌더한다 + // 로그인이면 내 리뷰도 받는다. 만료 등으로 실패해도 페이지는 그리고 다시 받는다(prefetchQuery) + const { isLoggedIn } = serverRequest; + const sort = parseReviewSort(search.sort); + try { + await Promise.all([ + queryClient.fetchQuery(storeMobileQueries.detail(id)), + // 리뷰 요청에는 쿠키가 실린다. 만료·무효 access 쿠키로 실패해도 넘어간다 + queryClient.prefetchQuery(storeMobileQueries.reviewList(id, 'LATEST', isLoggedIn)), + sort !== 'LATEST' ? queryClient.prefetchQuery(storeMobileQueries.reviewList(id, sort, isLoggedIn)) : null, + isLoggedIn ? queryClient.prefetchQuery(storeReviewsQueries.myReviews(id, sort, isLoggedIn)) : null, + ]); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } - if (!isLoggedIn) cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + if (!isLoggedIn) cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return { id, dehydratedState: dehydrate(queryClient) }; - }), - ); + return { id }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/$id/reviews/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/store.index.tsx b/apps/web/src/routes/store.index.tsx index 13a40def..af23dbdf 100644 --- a/apps/web/src/routes/store.index.tsx +++ b/apps/web/src/routes/store.index.tsx @@ -1,13 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { cn } from '@bcsdlab/utils'; -import { - dehydrate, - HydrationBoundary, - QueryClient, - useQuery, - useSuspenseQuery, - type DehydratedState, -} from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useSearch, useParams } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -35,7 +28,8 @@ import useBooleanState from 'utils/hooks/state/useBooleanState'; import { useLocalStorage } from 'utils/hooks/state/useLocalStorage'; import useMount from 'utils/hooks/state/useMount'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; import { isomorphicLocalStorage, isomorphicSessionStorage } from 'utils/ts/env'; import styles from 'pages/store/StorePage.module.scss'; @@ -355,11 +349,10 @@ function Store() { } interface StorePageProps { - dehydratedState: DehydratedState; isMobilePage?: boolean; } -function StorePage({ dehydratedState, isMobilePage }: StorePageProps) { +function StorePage({ isMobilePage }: StorePageProps) { if (isMobilePage) { return ( <> @@ -369,67 +362,55 @@ function StorePage({ dehydratedState, isMobilePage }: StorePageProps) { ); } - return ( - - - - ); + return ; } +const loadPage = definePageLoad(async ({ search, queryClient }, cacheControl, serverRequest) => { + // 모바일은 KOIN_ORDER_WEBVIEW 주변상점 화면을 그린다. order가 부르는 API(카테고리·v3 목록)만 미리 받아 본문까지 렌더한다 + if (serverRequest.device === 'mobile') { + const { sort, filter } = parseListQuery(search); + const { sorter, filter: listFilter } = getListRequestParams(sort, filter); + + await Promise.all([ + queryClient.prefetchQuery(storeMobileQueries.categories()), + queryClient.prefetchQuery(storeMobileQueries.list(sorter, listFilter)), + ]); + + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + + return { isMobilePage: true }; + } + + const { storeName } = search; + const selectedStoreName = storeName ? String(storeName) : undefined; + + await Promise.all([ + queryClient.prefetchQuery(storeQueries.categories()), + queryClient.prefetchQuery( + storeQueries.listV2({ + sorter: 'NONE', + filter: [], + query: selectedStoreName, + }), + ), + queryClient.prefetchQuery(storeQueries.allEvents()), + ]); + + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + + return { isMobilePage: false }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - // 모바일은 KOIN_ORDER_WEBVIEW 주변상점 화면을 그린다. order가 부르는 API(카테고리·v3 목록)만 서버에서 받아 본문까지 렌더한다 - if (serverRequest.device === 'mobile') { - const mobileQueryClient = new QueryClient(); - const { sort, filter } = parseListQuery(context.search); - const { sorter, filter: listFilter } = getListRequestParams(sort, filter); - - await Promise.all([ - mobileQueryClient.prefetchQuery(storeMobileQueries.categories()), - mobileQueryClient.prefetchQuery(storeMobileQueries.list(sorter, listFilter)), - ]); - - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - - return { - isMobilePage: true, - dehydratedState: dehydrate(mobileQueryClient), - }; - } - - const queryClient = new QueryClient(); - - const { storeName } = context.search; - const selectedStoreName = storeName ? String(storeName) : undefined; - - await Promise.all([ - queryClient.prefetchQuery(storeQueries.categories()), - queryClient.prefetchQuery( - storeQueries.listV2({ - sorter: 'NONE', - filter: [], - query: selectedStoreName, - }), - ), - queryClient.prefetchQuery(storeQueries.allEvents()), - ]); - - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - - return { - isMobilePage: false, - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); -// 정렬·필터·카테고리·검색어는 화면이 클라이언트 쿼리로 처리한다. search만 바뀌면 페이지 loader는 다시 실행하지 않는다. -// StoreListPage는 낙관적 상태로 선택을 즉시 반영한다. +// 정렬·필터·카테고리·검색어는 화면이 클라이언트 쿼리로 처리한다. 진입 시에만 현재 주소로 미리 받는다. +// search만 바뀌면 loader를 다시 실행하지 않고 StoreListPage가 낙관적 상태로 선택을 즉시 반영한다. export const Route = createFileRoute('/store/')({ validateSearch: (search: Record) => search as Record, - loader: ({ params, location }) => getPageData({ data: { params, search: location.search } }), + loader: ({ params, location, context }) => pageLoader({ params, search: location.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/store.review.$id.index.tsx b/apps/web/src/routes/store.review.$id.index.tsx index de9dcb93..89761f94 100644 --- a/apps/web/src/routes/store.review.$id.index.tsx +++ b/apps/web/src/routes/store.review.$id.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, useSearch, useParams, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -11,6 +10,7 @@ import { useAddStoreReview } from 'components/Store/StoreReviewPage/hooks/useAdd import ReviewForm from 'components/Store/StoreReviewPage/ReviewForm/ReviewForm'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -52,35 +52,35 @@ function AddReviewPage({ id, isMobile }: Props) { } // 로그인 필수 화면이라 공용 캐시를 켜지 않는다(loadPageData 기본값 private) +const loadPage = definePageLoad(async ({ params, queryClient }, _cacheControl, serverRequest) => { + const id = params.id; + if (!id) throw notFound(); + + // 데스크톱은 기존처럼 브라우저에서 그린다 + if (serverRequest.device !== 'mobile') { + return { id, isMobile: false }; + } + + // 모바일은 상점명을 미리 받아 본문까지 렌더한다 + try { + await queryClient.fetchQuery(storeMobileQueries.detail(id)); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } + + return { id, isMobile: true }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const id = context.params.id; - if (!id) throw notFound(); - - // 데스크톱은 기존처럼 클라이언트에서 그린다 - if (serverRequest.device !== 'mobile') { - return { id, isMobile: false }; - } - - // 모바일은 상점명을 서버에서 받아 본문까지 렌더한다(클라이언트는 같은 쿼리 키로 캐시를 이어받는다) - const queryClient = new QueryClient(); - try { - await queryClient.fetchQuery(storeMobileQueries.detail(id)); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } - - return { id, isMobile: true, dehydratedState: dehydrate(queryClient) }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/review/$id/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { requireAuth: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/store.review.edit.$id.$reviewid.index.tsx b/apps/web/src/routes/store.review.edit.$id.$reviewid.index.tsx index 85d95f29..c5ed880c 100644 --- a/apps/web/src/routes/store.review.edit.$id.$reviewid.index.tsx +++ b/apps/web/src/routes/store.review.edit.$id.$reviewid.index.tsx @@ -1,4 +1,4 @@ -import { dehydrate, QueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, useSearch, useParams, notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -12,6 +12,7 @@ import ReviewForm from 'components/Store/StoreReviewPage/ReviewForm/ReviewForm'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useMount from 'utils/hooks/state/useMount'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { isNotFoundKoinError } from 'utils/ts/isr'; interface Props { @@ -51,38 +52,38 @@ function EditReviewPage({ id, reviewId, isMobile }: Props) { } // 로그인 필수 화면이라 공용 캐시를 켜지 않는다(loadPageData 기본값 private) +const loadPage = definePageLoad(async ({ params, queryClient }, _cacheControl, serverRequest) => { + const { id, reviewid: reviewId } = params; + if (!id || !reviewId) throw notFound(); + + // 데스크톱은 기존처럼 브라우저에서 그린다 + if (serverRequest.device !== 'mobile') { + return { id, reviewId, isMobile: false }; + } + + // 모바일은 상점명과 기존 리뷰를 미리 받아 본문까지 렌더한다 + try { + await Promise.all([ + queryClient.fetchQuery(storeMobileQueries.detail(id)), + queryClient.fetchQuery(storeMobileQueries.reviewDetail(id, reviewId)), + ]); + } catch (error) { + if (isNotFoundKoinError(error)) throw notFound(); + throw error; + } + + return { id, reviewId, isMobile: true }; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const { id, reviewid: reviewId } = context.params; - if (!id || !reviewId) throw notFound(); - - // 데스크톱은 기존처럼 클라이언트에서 그린다 - if (serverRequest.device !== 'mobile') { - return { id, reviewId, isMobile: false }; - } - - // 모바일은 상점명과 기존 리뷰를 서버에서 받아 본문까지 렌더한다(클라이언트는 같은 쿼리 키로 캐시를 이어받는다) - const queryClient = new QueryClient(); - try { - await Promise.all([ - queryClient.fetchQuery(storeMobileQueries.detail(id)), - queryClient.fetchQuery(storeMobileQueries.reviewDetail(id, reviewId)), - ]); - } catch (error) { - if (isNotFoundKoinError(error)) throw notFound(); - throw error; - } - - return { id, reviewId, isMobile: true, dehydratedState: dehydrate(queryClient) }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/review/edit/$id/$reviewid/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { requireAuth: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/store.search.index.tsx b/apps/web/src/routes/store.search.index.tsx index 6c8935da..ae429ce9 100644 --- a/apps/web/src/routes/store.search.index.tsx +++ b/apps/web/src/routes/store.search.index.tsx @@ -5,7 +5,8 @@ import { SSRLayout } from 'components/layout'; import MobilePageHeader from 'components/layout/MobilePageHeader'; import StoreSearchPage from 'components/Store/mobile/StoreSearchPage'; import ROUTES from 'static/routes'; -import { loadPageData, validatePageInput, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, STORE_PUBLIC_SSR_CACHE_CONTROL } from 'utils/ssr/pageLoader'; function StoreSearchRoute() { return ( @@ -16,25 +17,26 @@ function StoreSearchRoute() { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, (_context, cacheControl, serverRequest) => { - // 모바일 전용 화면. 데스크톱은 서버가 확정한 기기 값으로 기존 화면에 보낸다 - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.Store(), statusCode: 302 }); - } +const loadPage = definePageLoad((_context, cacheControl, serverRequest) => { + // 모바일 전용 화면. 데스크톱은 확정된 기기 값으로 기존 화면에 보낸다 + if (serverRequest.device !== 'mobile') { + throw redirect({ href: ROUTES.Store(), statusCode: 302 }); + } - cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); + cacheControl.enablePublicCache(STORE_PUBLIC_SSR_CACHE_CONTROL); - return {}; - }), - ); + return {}; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/store/search/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/team.index.tsx b/apps/web/src/routes/team.index.tsx index 0c5d148d..8f20c9b4 100644 --- a/apps/web/src/routes/team.index.tsx +++ b/apps/web/src/routes/team.index.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { dehydrate, QueryClient, useInfiniteQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useLocation } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -28,6 +28,7 @@ import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useInfiniteScroll from 'utils/hooks/ui/useInfiniteScroll'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { redirectToLogin, setRedirectPath } from 'utils/ts/auth'; import showToast from 'utils/ts/showToast'; @@ -290,30 +291,25 @@ function TeamListPage() { ); } +const loadPage = definePageLoad(async ({ queryClient }, cacheControl, serverRequest) => { + await queryClient.prefetchInfiniteQuery(teamQueries.infiniteList(INITIAL_REQUEST_PARAMS, serverRequest.isLoggedIn)); + + if (!serverRequest.isLoggedIn) { + cacheControl.enablePublicCache(); + } + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - - await queryClient.prefetchInfiniteQuery( - teamQueries.infiniteList(INITIAL_REQUEST_PARAMS, serverRequest.isLoggedIn), - ); - - if (!serverRequest.isLoggedIn) { - cacheControl.enablePublicCache(); - } - - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/team/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { hasPageSeo: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/team.profile.edit.index.tsx b/apps/web/src/routes/team.profile.edit.index.tsx index 53ec09fc..a9a42450 100644 --- a/apps/web/src/routes/team.profile.edit.index.tsx +++ b/apps/web/src/routes/team.profile.edit.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -7,9 +6,10 @@ import Layout from 'components/layout'; import Seo from 'components/seo/Seo'; import TeamProfileForm from 'components/Team/ProfilePage'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; -// 로그인은 루트 라우트의 beforeLoad가 보장한다. 기존 프로필을 서버에서 미리 prefetch해 내려줘야 -// 폼이 "빈 값으로 시작했다가 나중에 채워지는" 하이드레이션 불일치 없이 처음부터 채워진 채로 렌더된다. +// 로그인은 루트 라우트의 beforeLoad가 보장한다. 기존 프로필을 SSR 또는 클라이언트 이동에서 +// 미리 채워 두어 폼이 처음부터 값이 채워진 채로 렌더된다. function TeamProfileEditPage() { return ( <> @@ -20,26 +20,23 @@ function TeamProfileEditPage() { ); } +const loadPage = definePageLoad(async ({ queryClient }, _cacheControl, serverRequest) => { + if (serverRequest.isLoggedIn) { + await queryClient.prefetchQuery(teamRecruitmentProfileQueries.me(serverRequest.isLoggedIn)); + } + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, _cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - - if (serverRequest.isLoggedIn) { - await queryClient.prefetchQuery(teamRecruitmentProfileQueries.me(serverRequest.isLoggedIn)); - } - - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/team/profile/edit/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { hasPageSeo: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/team.profile.index.tsx b/apps/web/src/routes/team.profile.index.tsx index c84cd0eb..af4f1e65 100644 --- a/apps/web/src/routes/team.profile.index.tsx +++ b/apps/web/src/routes/team.profile.index.tsx @@ -1,4 +1,4 @@ -import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -11,20 +11,20 @@ import ROUTES from 'static/routes'; import useLogger from 'utils/hooks/analytics/useLogger'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import styles from 'pages/team/profile/TeamProfilePage.module.scss'; -// 로그인은 루트 라우트의 beforeLoad가 보장하므로 여기선 세션이 항상 존재한다고 가정해도 된다. -// 프로필 조회를 서버에서 미리 끝내(prefetch) 클라이언트로 내려주므로, 클라이언트는 로딩 상태 자체를 -// 겪지 않고 처음부터 확정된 데이터로 렌더한다 — "프로필 없음"이 잠깐 보이는 깜빡임이 구조적으로 불가능해진다. +// 로그인은 루트 라우트의 beforeLoad가 보장한다. 프로필 쿼리를 SSR 또는 클라이언트 이동에서 +// 미리 채워 두므로, 처음부터 확정된 데이터로 렌더해 "프로필 없음" 깜빡임을 막는다. function TeamProfilePage() { const routeNavigate = useNavigate(); const isLoggedIn = useIsLoggedIn(); const logger = useLogger(); // 애플리케이션의 QueryClient는 SSR 중 모든 쿼리를 기본적으로 enabled:false로 끈다(전역 기본값). // useSuspenseQuery는 enabled를 지원하지 않아 이 기본값을 개별적으로 못 덮어써서 서버에서 빈 데이터로 - // 취급되므로, enabled를 명시할 수 있는 일반 useQuery를 쓴다. 위 getPageData가 이미 이 쿼리를 - // prefetch+dehydrate해뒀으므로, 서버·클라이언트 모두 첫 렌더부터 캐시에서 동기적으로 값을 읽는다. + // 취급되므로, enabled를 명시할 수 있는 일반 useQuery를 쓴다. 로더가 쿼리를 미리 채우므로 + // 서버·클라이언트 모두 첫 렌더부터 캐시에서 동기적으로 값을 읽는다. const { data: profile } = useQuery({ ...teamRecruitmentProfileQueries.me(isLoggedIn), enabled: isLoggedIn, @@ -89,26 +89,23 @@ function TeamProfilePage() { ); } +const loadPage = definePageLoad(async ({ queryClient }, _cacheControl, serverRequest) => { + if (serverRequest.isLoggedIn) { + await queryClient.prefetchQuery(teamRecruitmentProfileQueries.me(serverRequest.isLoggedIn)); + } + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, _cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - - if (serverRequest.isLoggedIn) { - await queryClient.prefetchQuery(teamRecruitmentProfileQueries.me(serverRequest.isLoggedIn)); - } - - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/team/profile/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), staticData: { hasPageSeo: true }, component: RouteComponent, }); diff --git a/apps/web/src/routes/timetable.index.tsx b/apps/web/src/routes/timetable.index.tsx index 90e27d30..54e7d4b7 100644 --- a/apps/web/src/routes/timetable.index.tsx +++ b/apps/web/src/routes/timetable.index.tsx @@ -1,10 +1,10 @@ import { lazy, useEffect, useState } from 'react'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; +import type { QueryClient } from '@tanstack/react-query'; import { createFileRoute, useSearch, useParams } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import { deptQueries } from 'api/dept/queries'; -import type { TimetableFrameListResponse } from 'api/timetable/entity'; +import type { Semester, TimetableFrameListResponse } from 'api/timetable/entity'; import { createDefaultTimetableFrameList, isValidTimetableFrameId, @@ -21,7 +21,8 @@ import useLogger from 'utils/hooks/analytics/useLogger'; import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; import useGoBack from 'utils/hooks/routing/useGoBack'; import useScrollToTop from 'utils/hooks/ui/useScrollToTop'; -import { loadPageData, validatePageInput, type PageRequest } from 'utils/ssr/pageData'; +import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad, type PageLoadContext } from 'utils/ssr/pageLoader'; import type { ServerRequestContext } from 'utils/ssr/requestContext'; import { getRecentSemester, getSemesterFromQuery, resolveTimetableSemester } from 'utils/timetable/semester'; import { isomorphicSessionStorage } from 'utils/ts/env'; @@ -42,21 +43,17 @@ const prefetchBaseTimetableData = async (queryClient: QueryClient) => { ]); }; -const setDefaultTimetableFrameList = ( - queryClient: QueryClient, - isLoggedIn: boolean, - semester = getRecentSemester(), -) => { +const setDefaultTimetableFrameList = (queryClient: QueryClient, isLoggedIn: boolean, semester: Semester) => { queryClient.setQueryData(timetableQueryKeys.frameList(semester, isLoggedIn), createDefaultTimetableFrameList()); }; async function prefetchTimetableData( queryClient: QueryClient, - context: PageRequest, isLoggedIn: boolean, userType: ServerRequestContext['userType'], - query: PageRequest['search'], + query: PageLoadContext['search'], validatedFrameId: number | null, + recentSemester: Semester, ): Promise { // 학기·학과 목록은 사용자 학기와 무관하므로 먼저 띄워 두고 각 분기에서 기다린다. prefetchQuery는 실패해도 reject하지 않는다 const baseTimetablePrefetch = prefetchBaseTimetableData(queryClient); @@ -67,7 +64,7 @@ async function prefetchTimetableData( const semester = resolveTimetableSemester(query.year, query.term, userSemester); if (!semester) { - setDefaultTimetableFrameList(queryClient, isLoggedIn); + setDefaultTimetableFrameList(queryClient, isLoggedIn, recentSemester); await baseTimetablePrefetch; return; @@ -107,7 +104,7 @@ async function prefetchTimetableData( setDefaultTimetableFrameList( queryClient, isLoggedIn, - getSemesterFromQuery(query.year, query.term) ?? getRecentSemester(), + getSemesterFromQuery(query.year, query.term) ?? recentSemester, ); await baseTimetablePrefetch; } @@ -167,39 +164,36 @@ function TimetablePage() { ); } +const loadPage = definePageLoad(async ({ search: query, queryClient }, cacheControl, serverRequest) => { + const frameId = Number(query.timetableFrameId); + const validatedFrameId = isValidTimetableFrameId(frameId) ? frameId : null; + + if (serverRequest.isLoggedIn) { + await prefetchTimetableData( + queryClient, + serverRequest.isLoggedIn, + serverRequest.userType, + query, + validatedFrameId, + getRecentSemester(new Date(serverRequest.now)), + ); + } else { + setDefaultTimetableFrameList(queryClient, false, getRecentSemester(new Date(serverRequest.now))); + cacheControl.enablePublicCache(); + } + + return {}; +}); + const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - const { search: query } = context; - const frameId = Number(query.timetableFrameId); - const validatedFrameId = isValidTimetableFrameId(frameId) ? frameId : null; - - if (serverRequest.isLoggedIn) { - await prefetchTimetableData( - queryClient, - context, - serverRequest.isLoggedIn, - serverRequest.userType, - query, - validatedFrameId, - ); - } else { - setDefaultTimetableFrameList(queryClient, false); - cacheControl.enablePublicCache(); - } - - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/timetable/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/timetable.list.index.tsx b/apps/web/src/routes/timetable.list.index.tsx index 12ad51a3..eb95e023 100644 --- a/apps/web/src/routes/timetable.list.index.tsx +++ b/apps/web/src/routes/timetable.list.index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -9,6 +8,7 @@ import MobileTimetableListPage from 'components/TimetablePage/MobileTimetableLis import { isKoinError } from 'interfaces/APIError'; import ROUTES from 'static/routes'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { isUnauthorizedError } from 'utils/ts/httpError'; function TimetableListPage() { @@ -19,54 +19,50 @@ function TimetableListPage() { ); } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (_context, cacheControl, serverRequest) => { - // 모바일 전용 화면: 데스크탑은 시간표 페이지로 보낸다. - if (serverRequest.device === 'desktop') { - throw redirect({ href: ROUTES.Timetable(), statusCode: 302 }); - } +const loadPage = definePageLoad(async ({ queryClient }, cacheControl, serverRequest) => { + // 모바일 전용 화면: 데스크탑은 시간표 페이지로 보낸다. + if (serverRequest.device === 'desktop') { + throw redirect({ href: ROUTES.Timetable(), statusCode: 302 }); + } - const queryClient = new QueryClient(); - const { userType } = serverRequest; - let isMember = false; + const { userType } = serverRequest; + let isMember = false; - if (serverRequest.isLoggedIn) { - try { - const mySemester = await queryClient.fetchQuery(timetableQueries.mySemester(true, { userType })); - if (mySemester) { - isMember = true; - await Promise.all( - mySemester.semesters.map((semester) => - queryClient.prefetchQuery( - timetableQueries.frameList(true, semester, { fallbackOnError: true, userType }), - ), - ), - ); - } - } catch (error) { - // userType 가드로 학생이 아닌 사용자는 요청 자체를 건너뛰지만, 판별 실패 등으로 요청이 나간 경우의 방어선이다. - const isForbiddenError = isKoinError(error) && error.status === 403; - if (!isUnauthorizedError(error) && !isForbiddenError) throw error; - } + if (serverRequest.isLoggedIn) { + try { + const mySemester = await queryClient.fetchQuery(timetableQueries.mySemester(true, { userType })); + if (mySemester) { + isMember = true; + await Promise.all( + mySemester.semesters.map((semester) => + queryClient.prefetchQuery(timetableQueries.frameList(true, semester, { fallbackOnError: true, userType })), + ), + ); } + } catch (error) { + // userType 가드로 학생이 아닌 사용자는 요청 자체를 건너뛰지만, 판별 실패 등으로 요청이 나간 경우의 방어선이다. + const isForbiddenError = isKoinError(error) && error.status === 403; + if (!isUnauthorizedError(error) && !isForbiddenError) throw error; + } + } - if (!isMember) { - await queryClient.prefetchQuery(timetableQueries.semesterInfo()); - if (!serverRequest.isLoggedIn) cacheControl.enablePublicCache(); - } + if (!isMember) { + await queryClient.prefetchQuery(timetableQueries.semesterInfo()); + if (!serverRequest.isLoggedIn) cacheControl.enablePublicCache(); + } - return { - dehydratedState: dehydrate(queryClient), - }; - }), - ); + return {}; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/timetable/list/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/routes/timetable.modify.index.tsx b/apps/web/src/routes/timetable.modify.index.tsx index 871e3db8..9ff3d034 100644 --- a/apps/web/src/routes/timetable.modify.index.tsx +++ b/apps/web/src/routes/timetable.modify.index.tsx @@ -1,4 +1,3 @@ -import { dehydrate, QueryClient } from '@tanstack/react-query'; import { createFileRoute, useSearch, useParams } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; @@ -8,6 +7,7 @@ import { SSRLayout } from 'components/layout'; import ModifyTimetablePage from 'components/TimetablePage/ModifyTimetablePage'; import { isKoinError } from 'interfaces/APIError'; import { loadPageData, validatePageInput } from 'utils/ssr/pageData'; +import { createPageLoader, definePageLoad } from 'utils/ssr/pageLoader'; import { getRecentSemester, getSemesterFromQuery, resolveTimetableSemester } from 'utils/timetable/semester'; import { isUnauthorizedError } from 'utils/ts/httpError'; @@ -26,51 +26,46 @@ function ModifyTimetablePageWrapper({ semester }: { semester: Semester }) { return ; } -const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) - .validator(validatePageInput) - .handler(({ data }) => - loadPageData(data, async (context, _cacheControl, serverRequest) => { - const queryClient = new QueryClient(); - - const { search: query } = context; - const { isLoggedIn, userType } = serverRequest; - const timetableFrameId = Number(query.id); - let currentSemester = getSemesterFromQuery(query.year, query.term) ?? getRecentSemester(); +const loadPage = definePageLoad(async ({ search: query, queryClient }, _cacheControl, serverRequest) => { + const { isLoggedIn, userType } = serverRequest; + const timetableFrameId = Number(query.id); + let currentSemester = getSemesterFromQuery(query.year, query.term) ?? getRecentSemester(new Date(serverRequest.now)); - if (isLoggedIn && userType === 'STUDENT') { - try { - const mySemesterData = await queryClient.fetchQuery(timetableQueries.mySemester(isLoggedIn, { userType })); - const userSemester = mySemesterData?.semesters?.[0]; - const semester = resolveTimetableSemester(query.year, query.term, userSemester); - currentSemester = semester ?? currentSemester; + if (isLoggedIn && userType === 'STUDENT') { + try { + const mySemesterData = await queryClient.fetchQuery(timetableQueries.mySemester(isLoggedIn, { userType })); + const userSemester = mySemesterData?.semesters?.[0]; + const semester = resolveTimetableSemester(query.year, query.term, userSemester); + currentSemester = semester ?? currentSemester; - const prefetchPromises = [ - queryClient.prefetchQuery(timetableQueries.lectureInfo(isLoggedIn, timetableFrameId)), - ]; + const prefetchPromises = [queryClient.prefetchQuery(timetableQueries.lectureInfo(isLoggedIn, timetableFrameId))]; - if (semester) { - prefetchPromises.push(queryClient.prefetchQuery(timetableQueries.lectureList(semester))); - } + if (semester) { + prefetchPromises.push(queryClient.prefetchQuery(timetableQueries.lectureList(semester))); + } - await Promise.all(prefetchPromises); - } catch (error) { - if (!isUnauthorizedError(error) && !(isKoinError(error) && error.status === 403)) { - throw error; - } - } + await Promise.all(prefetchPromises); + } catch (error) { + if (!isUnauthorizedError(error) && !(isKoinError(error) && error.status === 403)) { + throw error; } + } + } - return { - dehydratedState: dehydrate(queryClient), - semester: currentSemester, - }; - }), - ); + return { + semester: currentSemester, + }; +}); + +const getPageData = createServerFn({ method: 'GET', strict: { output: false } }) + .validator(validatePageInput) + .handler(({ data }) => loadPageData(data, loadPage)); +const pageLoader = createPageLoader(getPageData, loadPage); export const Route = createFileRoute('/timetable/modify/')({ validateSearch: (search: Record) => search as Record, loaderDeps: ({ search }) => ({ search }), - loader: ({ params, deps }) => getPageData({ data: { params, search: deps.search } }), + loader: ({ params, deps, context }) => pageLoader({ params, search: deps.search }, context), component: RouteComponent, }); diff --git a/apps/web/src/utils/auth/fetchSession.ts b/apps/web/src/utils/auth/fetchSession.ts index a0ee34bc..18b4ed74 100644 --- a/apps/web/src/utils/auth/fetchSession.ts +++ b/apps/web/src/utils/auth/fetchSession.ts @@ -1,13 +1,16 @@ import { getWebSession } from 'api/auth'; -import { ANONYMOUS_SESSION, authenticatedSession, hasSessionCookie, type Session, toUserType } from './session'; +import { ANONYMOUS_SESSION, authenticatedSession, type Session, toUserType } from './session'; +import { confirmSession, prepareSessionFetch, sessionCookie } from './sessionConfirmation'; // CSRF 쿠키가 없으면 세션이 없다고 보고 요청하지 않는다. export default async function fetchSession(): Promise { - if (!hasSessionCookie()) return ANONYMOUS_SESSION; + const cookie = sessionCookie(); + prepareSessionFetch(cookie); + if (!cookie) return confirmSession(cookie, ANONYMOUS_SESSION); const { authenticated, user_type: userType } = await getWebSession(); const resolved = authenticated ? toUserType(userType) : null; - return resolved ? authenticatedSession(resolved) : ANONYMOUS_SESSION; + return confirmSession(cookie, resolved ? authenticatedSession(resolved) : ANONYMOUS_SESSION); } diff --git a/apps/web/src/utils/auth/sessionConfirmation.ts b/apps/web/src/utils/auth/sessionConfirmation.ts new file mode 100644 index 00000000..6d188e2e --- /dev/null +++ b/apps/web/src/utils/auth/sessionConfirmation.ts @@ -0,0 +1,37 @@ +import { WEB_AUTH_CSRF_COOKIE_KEY } from 'static/url'; +import { ANONYMOUS_SESSION, SESSION_QUERY_KEY, type Session } from 'utils/auth/session'; +import { getCookie } from 'utils/ts/cookie'; +import { queryClient } from 'utils/ts/queryClient'; + +let confirmedCookie: string | undefined; +let initialized = false; + +export const sessionCookie = () => (typeof document === 'undefined' ? undefined : getCookie(WEB_AUTH_CSRF_COOKIE_KEY)); + +export function initializeSessionConfirmation(isLoggedIn: boolean) { + if (initialized || typeof document === 'undefined') return; + initialized = true; + if (isLoggedIn) confirmedCookie = sessionCookie(); +} + +export function isConfirmedSession(cookie: string | undefined) { + return !!cookie && cookie === confirmedCookie; +} + +export function prepareSessionFetch(cookie: string | undefined) { + const cached = queryClient.getQueryData(SESSION_QUERY_KEY); + if ((confirmedCookie && confirmedCookie !== cookie) || (!cookie && cached?.status === 'authenticated')) { + queryClient.removeQueries({ + predicate: (query) => query.queryKey[0] !== SESSION_QUERY_KEY[0] || query.queryKey[1] !== SESSION_QUERY_KEY[1], + }); + queryClient.setQueryData(SESSION_QUERY_KEY, ANONYMOUS_SESSION); + confirmedCookie = undefined; + } +} + +export function confirmSession(cookie: string | undefined, session: Session) { + if (cookie !== sessionCookie()) throw new Error('Session cookie changed during request'); + confirmedCookie = cookie; + + return session; +} diff --git a/apps/web/src/utils/ssr/pageData.ts b/apps/web/src/utils/ssr/pageData.ts index 0ddb35f0..b64271b9 100644 --- a/apps/web/src/utils/ssr/pageData.ts +++ b/apps/web/src/utils/ssr/pageData.ts @@ -1,24 +1,13 @@ -import { getRequest, getResponseHeader, setResponseHeader } from '@tanstack/react-start/server'; +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import { getCookie, getRequest, getResponseHeader, setResponseHeader } from '@tanstack/react-start/server'; import { runWithServerRequestHeaders } from 'utils/ssr/cookieForwarding'; -import { getServerRequestContext, type ServerRequestContext } from 'utils/ssr/requestContext'; +import type { PageData, PageInput, PageLoad, PageSearch } from 'utils/ssr/pageLoader'; +import { getServerRequestContext } from 'utils/ssr/requestContext'; export const PUBLIC_SSR_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=300'; -export const STORE_PUBLIC_SSR_CACHE_CONTROL = 'public, s-maxage=300, stale-while-revalidate=1800'; export const PRIVATE_SSR_CACHE_CONTROL = 'private, no-store'; -export type PageSearch = Record; -export interface PageInput { - params: Record; - search: PageSearch; -} -export interface PageRequest extends PageInput { - request: Request; -} -export interface PageCacheControl { - enablePublicCache: (cacheControl?: string) => void; -} - export function validatePageInput(value: unknown): PageInput { if (!value || typeof value !== 'object' || !('params' in value) || !('search' in value)) { throw new TypeError('Invalid route input'); @@ -54,23 +43,20 @@ export function validatePageInput(value: unknown): PageInput { return { params: validParams, search: validSearch }; } -/** 요청 쿠키와 서버 스냅샷은 클라이언트 입력이 아니라 현재 HTTP 요청에서만 얻는다. */ +/** SSR의 load 실행. 요청 쿠키와 서버 스냅샷은 클라이언트 입력이 아니라 현재 HTTP 요청에서만 얻는다. */ export async function loadPageData( input: PageInput, - load: ( - context: PageRequest, - cacheControl: PageCacheControl, - serverRequest: ServerRequestContext, - ) => Props | Promise, -) { + load: PageLoad, +): Promise> { const request = getRequest(); const cookie = request.headers.get('cookie') ?? ''; const serverRequest = await getServerRequestContext(request); + const queryClient = new QueryClient(); let policy = PRIVATE_SSR_CACHE_CONTROL; setResponseHeader('Cache-Control', policy); const props = await runWithServerRequestHeaders({ cookie, origin: new URL(request.url).origin }, () => load( - { ...input, request }, + { ...input, queryClient, getCookie: (name) => getCookie(name) }, { enablePublicCache: (cacheControl = PUBLIC_SSR_CACHE_CONTROL) => { // 세션 만료·갱신 중인 방문자도 쿠키가 있으면 공유 캐시에서 제외한다. @@ -80,7 +66,9 @@ export async function loadPageData( serverRequest, ), ); - setResponseHeader('Cache-Control', getResponseHeader('Set-Cookie') ? PRIVATE_SSR_CACHE_CONTROL : policy); + const finalPolicy = getResponseHeader('Set-Cookie') ? PRIVATE_SSR_CACHE_CONTROL : policy; + setResponseHeader('Cache-Control', finalPolicy); + serverRequest.mayBeSharedCached = finalPolicy !== PRIVATE_SSR_CACHE_CONTROL; - return { ...props, serverRequest }; + return { ...props, serverRequest, dehydratedState: dehydrate(queryClient) }; } diff --git a/apps/web/src/utils/ssr/pageLoader.ts b/apps/web/src/utils/ssr/pageLoader.ts new file mode 100644 index 00000000..9186c820 --- /dev/null +++ b/apps/web/src/utils/ssr/pageLoader.ts @@ -0,0 +1,126 @@ +import type { DehydratedState, QueryClient } from '@tanstack/react-query'; + +import { WebSession } from 'api/auth/APIDetail'; +import { authQueries, SESSION_STALE_TIME } from 'api/auth/queries'; +import { isHttpError } from 'interfaces/APIError'; +import { ANONYMOUS_SESSION, SESSION_QUERY_KEY, type Session } from 'utils/auth/session'; +import { isConfirmedSession, prepareSessionFetch, sessionCookie } from 'utils/auth/sessionConfirmation'; +import type { ServerRequestContext } from 'utils/ssr/requestContext'; +import { getFreshServerNow, initializeServerClock } from 'utils/ssr/serverClock'; +import APIClient from 'utils/ts/apiClient'; +import { getCookie } from 'utils/ts/cookie'; +import { queryClient } from 'utils/ts/queryClient'; + +// 서버 전용 모듈(@tanstack/react-start/server)을 가져오지 않는다. 라우트 loader가 브라우저에서도 이 모듈을 실행한다. + +/** load가 넘기는 상점 화면 공유 캐시 정책. SSR 응답에만 적용되고 브라우저 이동에서는 무시된다. */ +export const STORE_PUBLIC_SSR_CACHE_CONTROL = 'public, s-maxage=300, stale-while-revalidate=1800'; + +export type PageSearch = Record; +export interface PageInput { + params: Record; + search: PageSearch; +} +export interface PageCacheControl { + enablePublicCache: (cacheControl?: string) => void; +} +export interface PageLoadContext extends PageInput { + /** 미리 받을 쿼리를 채울 QueryClient. SSR은 요청마다 새로 만들고, 브라우저 이동은 앱 QueryClient를 그대로 쓴다. */ + queryClient: QueryClient; + /** SSR은 요청 Cookie 헤더, 브라우저 이동은 document.cookie에서 읽는다. */ + getCookie: (name: string) => string | undefined; +} +export type PageLoad = ( + context: PageLoadContext, + cacheControl: PageCacheControl, + serverRequest: ServerRequestContext, +) => Props | Promise; +/** SSR은 미리 받은 쿼리를 dehydratedState로 넘긴다. 브라우저 이동은 앱 QueryClient에 바로 채우므로 넘기지 않는다. */ +export type PageData = Props & { + serverRequest: ServerRequestContext; + dehydratedState?: DehydratedState; +}; +export interface RootSnapshot { + serverRequest: ServerRequestContext; + hasSession: boolean; +} + +const CLIENT_CACHE_CONTROL: PageCacheControl = { enablePublicCache: () => {} }; +const clockClient = new APIClient(); +clockClient.timeout = 2500; + +/** load의 인자 타입을 정해 준다. 라우트는 load를 모듈 최상위에 두고 서버 함수와 createPageLoader에 같이 넘긴다. */ +export const definePageLoad = (load: PageLoad) => load; + +/** + * 라우트 loader. SSR은 서버 함수로 요청 쿠키·캐시 정책과 함께 실행한다. + * 하이드레이션 이후 화면 이동은 같은 load를 브라우저에서 실행해 서버 함수를 거치지 않고 API를 직접 부른다. + */ +export function createPageLoader( + serverFn: (options: { data: PageInput }) => Promise>>, + load: PageLoad, +) { + return async (input: PageInput, context: { serverRequest: ServerRequestContext }): Promise> => { + if (typeof window === 'undefined') return serverFn({ data: input }); + + // load가 serverRequest를 고쳐 쓸 수 있으므로 루트 컨텍스트와 분리한다 + const serverRequest = { ...context.serverRequest }; + const props = await load({ ...input, queryClient, getCookie }, CLIENT_CACHE_CONTROL, serverRequest); + + return { ...props, serverRequest }; + }; +} + +/** + * 기기는 SSR 값, 인증은 CSRF에 결부된 세션을 사용한다. + * 공유 캐시 문서는 새 Date 표본이 없으면 이번 이동에만 SSR 시각 기준 오프셋을 사용한다. + */ +export async function getClientRootSnapshot(ssrServerRequest: ServerRequestContext): Promise { + initializeServerClock(ssrServerRequest.now, ssrServerRequest.mayBeSharedCached); + const cookie = sessionCookie(); + prepareSessionFetch(cookie); + const state = queryClient.getQueryState(SESSION_QUERY_KEY); + let session: Session = ANONYMOUS_SESSION; + let sessionFailedWithoutResponse = false; + if (cookie) { + if ( + isConfirmedSession(cookie) && + state?.data && + !state.isInvalidated && + Date.now() - state.dataUpdatedAt < SESSION_STALE_TIME + ) { + session = state.data as Session; + } else { + try { + session = await queryClient.fetchQuery({ ...authQueries.session(), staleTime: 0, retry: false }); + if (!isConfirmedSession(sessionCookie())) session = ANONYMOUS_SESSION; + } catch (error) { + const cached = queryClient.getQueryData(SESSION_QUERY_KEY); + session = isConfirmedSession(cookie) && cached ? cached : ANONYMOUS_SESSION; + sessionFailedWithoutResponse = isHttpError(error) && error.status === undefined; + } + } + } + // 시계 표본 조회의 401이 인증 갱신을 재귀적으로 시작하지 않도록 한다. + const now = await getFreshServerNow( + () => clockClient.request(Object.assign(new WebSession(), { skipAuthRefresh: true })), + sessionFailedWithoutResponse, + ); + const currentCookie = sessionCookie(); + if (currentCookie !== cookie) { + prepareSessionFetch(currentCookie); + session = ANONYMOUS_SESSION; + } + const userType = session.status === 'authenticated' ? session.userType : null; + + return { + serverRequest: { + device: ssrServerRequest.device, + isLoggedIn: userType !== null, + userType, + now, + mayBeSharedCached: false, + }, + hasSession: !!currentCookie, + }; +} diff --git a/apps/web/src/utils/ssr/requestContext.ts b/apps/web/src/utils/ssr/requestContext.ts index 00910f0e..51e3aa53 100644 --- a/apps/web/src/utils/ssr/requestContext.ts +++ b/apps/web/src/utils/ssr/requestContext.ts @@ -10,13 +10,10 @@ export interface ServerRequestContext { isLoggedIn: boolean; /** `GET /v2/web/auth/session`으로 서버가 직접 확인한 값. 로그인 상태일 때만 의미 있다. */ userType: UserType | null; - /** - * 서버 렌더 시각(ISO). 시각 파생 렌더의 공통 기준값이다. - * - * 공유 캐시로 최대 60초 낡을 수 있으나, 클라이언트가 자기 기기 시계로 다시 계산해 - * 매번 DOM을 갈아치우는 것보다 낫다. - */ + /** 서버 렌더 시각(ISO). 공유 캐시된 문서에서는 오래됐을 수 있다. */ now: string; + /** 문서가 공유 캐시에서 제공될 수 있으면 클라이언트는 새 서버 시각을 받아야 한다. */ + mayBeSharedCached: boolean; } /** @@ -75,15 +72,15 @@ export async function getServerRequestContext(request: Request): Promise entry.startsWith(`${WEB_AUTH_ACCESS_COOKIE_KEY}=`)) ?.slice(WEB_AUTH_ACCESS_COOKIE_KEY.length + 1); if (!cookie || !hasUsableAccessToken(accessToken)) { - return { device, isLoggedIn: false, userType: null, now }; + return { device, isLoggedIn: false, userType: null, now, mayBeSharedCached: true }; } try { const session = await APIClient.request(new WebSession({ Cookie: cookie, Origin: new URL(request.url).origin })); const userType = session.authenticated ? toUserType(session.user_type) : null; - return { device, isLoggedIn: userType !== null, userType, now }; + return { device, isLoggedIn: userType !== null, userType, now, mayBeSharedCached: userType === null }; } catch { - return { device, isLoggedIn: false, userType: null, now }; + return { device, isLoggedIn: false, userType: null, now, mayBeSharedCached: true }; } } diff --git a/apps/web/src/utils/ssr/serverClock.ts b/apps/web/src/utils/ssr/serverClock.ts new file mode 100644 index 00000000..e9a6d011 --- /dev/null +++ b/apps/web/src/utils/ssr/serverClock.ts @@ -0,0 +1,39 @@ +let offset: number | undefined; +let fallbackOffset: number | undefined; +let pending: Promise | undefined; + +/** Date 헤더는 초 단위로 잘리므로 구간의 중간값을 사용한다. */ +export function sampleServerClock(response: Response) { + const date = response.headers.get('Date'); + const time = date ? Date.parse(date) : NaN; + if (!Number.isFinite(time)) return; + const sampled = time + 500 - Date.now(); + // 초 단위 Date 표본의 오차로 기존 오프셋을 조금 뒤로 돌리지 않는다. + if (offset !== undefined && sampled < offset && offset - sampled < 1000) return; + offset = sampled; +} + +export function initializeServerClock(now: string, mayBeSharedCached: boolean) { + if (fallbackOffset !== undefined) return; + const [navigation] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]; + fallbackOffset = Date.parse(now) - (performance.timeOrigin + (navigation?.responseStart ?? 0)); + if (offset === undefined && !mayBeSharedCached) { + offset = fallbackOffset; + } +} + +export async function getFreshServerNow(fetchSession: () => Promise, skipSample = false): Promise { + if (offset === undefined && !skipSample) { + pending ??= fetchSession() + .then( + () => {}, + () => {}, + ) + .finally(() => { + pending = undefined; + }); + await pending; + } + + return new Date(Date.now() + (offset ?? fallbackOffset ?? 0)).toISOString(); +} diff --git a/apps/web/src/utils/ssr/useServerNow.ts b/apps/web/src/utils/ssr/useServerNow.ts index 719231b6..42be054e 100644 --- a/apps/web/src/utils/ssr/useServerNow.ts +++ b/apps/web/src/utils/ssr/useServerNow.ts @@ -7,10 +7,8 @@ import { toKoreanCalendarDate } from 'utils/ts/calendar'; * 서버가 요청을 렌더한 절대 시각이다. 경과 시간 계산에는 이 값을 그대로 쓴다. * 로컬 날짜 getter를 사용하는 달력은 KST 날짜를 보장하는 `useServerCalendarDate`를 쓴다. * - * 렌더 중 `new Date()`를 부르면 서버와 클라이언트가 서로 다른 시각을 보고, 하이드레이션 렌더가 - * 서버가 그린 DOM과 어긋나 통째로 교체된다. 공유 캐시 때문에 최대 60초 낡을 수 있지만 그쪽이 - * 매 로드마다 DOM을 갈아치우는 것보다 낫다. 실제 현재 시각이 필요하면 이벤트 핸들러에서 읽거나 - * `useLiveNow`로 마운트 뒤 타이머에서 갱신한다. + * 렌더 중 `new Date()`를 부르면 서버와 클라이언트가 서로 다른 시각을 보고 하이드레이션이 어긋난다. + * 실제 현재 시각이 필요하면 이벤트 핸들러에서 읽거나 `useLiveNow`로 마운트 뒤 타이머에서 갱신한다. * * 같은 요청 안에서는 같은 `Date` 인스턴스를 돌려주므로 의존성 배열에 그대로 써도 된다. * 루트 로더가 실행되지 않은 화면(`notFound`·오류 화면)은 서버 시각이 없어 마운트 시점에 한 번 샘플한다. diff --git a/apps/web/src/utils/timetable/semester.ts b/apps/web/src/utils/timetable/semester.ts index 08c69e43..c9ca6b76 100644 --- a/apps/web/src/utils/timetable/semester.ts +++ b/apps/web/src/utils/timetable/semester.ts @@ -1,4 +1,5 @@ import type { Semester, Term } from 'api/timetable/entity'; +import { kstDateKey } from 'utils/ts/calendar'; type QueryValue = string | string[] | undefined; @@ -28,10 +29,9 @@ export function resolveTimetableSemester(year: QueryValue, term: QueryValue, use return null; } -export function getRecentSemester(): Semester { - const date = new Date(); - const year = date.getFullYear(); - const month = date.getMonth() + 1; +// 기준 시각을 넘기지 않으면 실행 환경의 현재 시각을 쓴다. 페이지 load는 serverRequest.now를 넘긴다 +export function getRecentSemester(date: Date = new Date()): Semester { + const [year, month] = kstDateKey(date).split('-').map(Number); if (month < 2) { return { diff --git a/apps/web/src/utils/ts/apiClient.ts b/apps/web/src/utils/ts/apiClient.ts index aadd0b93..8f9c6827 100644 --- a/apps/web/src/utils/ts/apiClient.ts +++ b/apps/web/src/utils/ts/apiClient.ts @@ -9,6 +9,7 @@ import { hasSessionCookie } from 'utils/auth/session'; import { markSessionAuthenticated, markSessionExpired } from 'utils/auth/sessionControl'; import { fetchApiRequest, getApiBaseUrl } from 'utils/ssr/apiTarget'; import { getServerRequestHeaders } from 'utils/ssr/cookieForwarding'; +import { sampleServerClock } from 'utils/ssr/serverClock'; import qsStringify from 'utils/ts/qsStringfy'; import { getCookie } from './cookie'; @@ -183,11 +184,13 @@ export default class APIClient { headers: this.createHeaders(request), body: this.body(request), signal: controller.signal, + cache: request instanceof WebSession ? 'no-store' : undefined, credentials: request.withCredentials === false ? 'omit' : 'include', // Java SSR 요청의 쿠키가 리다이렉트 대상에 전달되지 않도록 기존 보호를 유지한다. redirect: isBrowser ? 'follow' : 'manual', }), ); + if (isBrowser && request instanceof WebSession) sampleServerClock(response); const text = await response.text(); let data: unknown = text; if (text) { diff --git a/apps/web/src/utils/ts/calendar.ts b/apps/web/src/utils/ts/calendar.ts index a29293f5..ad1df51b 100644 --- a/apps/web/src/utils/ts/calendar.ts +++ b/apps/web/src/utils/ts/calendar.ts @@ -1,15 +1,37 @@ import { DAYS } from 'static/day'; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** KST 기준 달력일 키(YYYY-MM-DD). */ +export function kstDateKey(date: Date): string { + return new Date(date.getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); +} + /** * 절대 시각의 KST 날짜를 달력용 로컬 자정으로 옮긴다. * 반환값은 날짜 필드·달력끼리의 비교에만 쓰며, 경과 시간이나 실제 시각 비교에는 쓰지 않는다. */ export function toKoreanCalendarDate(instant: Date): Date { - const korean = new Date(instant.getTime() + 9 * 60 * 60 * 1000); + const korean = new Date(instant.getTime() + KST_OFFSET_MS); return new Date(korean.getUTCFullYear(), korean.getUTCMonth(), korean.getUTCDate()); } +/** 절대 시각의 KST 요일과 시각. */ +export function kstDayOfWeek(date: Date): number { + return new Date(date.getTime() + KST_OFFSET_MS).getUTCDay(); +} + +export function kstHour(date: Date): number { + return new Date(date.getTime() + KST_OFFSET_MS).getUTCHours(); +} + +export function kstTimeOn(base: Date, hours: number, minutes: number): Date { + const kst = new Date(base.getTime() + KST_OFFSET_MS); + + return new Date(Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate(), hours, minutes) - KST_OFFSET_MS); +} + export interface CalendarCell { date: Date; currentMonth: boolean; diff --git a/apps/web/test/page-loader-clock-timeout.test.ts b/apps/web/test/page-loader-clock-timeout.test.ts new file mode 100644 index 00000000..f2488e79 --- /dev/null +++ b/apps/web/test/page-loader-clock-timeout.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; + +test('공유 문서의 응답 없는 시계 표본은 짧은 타임아웃 뒤 SSR 기준 오프셋을 사용한다', async (t) => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'https://example.test' } }, + }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: { cookie: '' } }); + t.after(() => { + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, 'document'); + }); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') + return { + url: 'data:text/javascript,export const env = {KOIN_ENVIRONMENT:"preview",KOIN_API_ORIGIN:"https://api.stage.koreatech.in"}', + shortCircuit: true, + }; + return nextResolve(specifier, context); + }, + }); + t.after(() => hooks.deregister()); + t.mock.method(globalThis, 'fetch', () => new Promise(() => {})); + // APIClient의 브라우저 판별과 Cloudflare 모듈 대체를 먼저 설치한다. + const { getClientRootSnapshot } = await import('../src/utils/ssr/pageLoader'); + const start = performance.now(); + const now = new Date(performance.timeOrigin + 10_000).toISOString(); + const result = await getClientRootSnapshot({ + device: 'desktop', + isLoggedIn: false, + userType: null, + now, + mayBeSharedCached: true, + }); + const elapsed = performance.now() - start; + assert.ok(elapsed >= 2000 && elapsed < 5000, `elapsed: ${elapsed}`); + assert.ok(Math.abs(Date.parse(result.serverRequest.now) - (Date.now() + 10_000)) < 1000); +}); diff --git a/apps/web/test/page-loader-offline.test.ts b/apps/web/test/page-loader-offline.test.ts new file mode 100644 index 00000000..5b8a4929 --- /dev/null +++ b/apps/web/test/page-loader-offline.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; +import { WEB_AUTH_CSRF_COOKIE_KEY } from '../src/static/url'; + +test('응답 없는 세션 실패는 최신 캐시를 사용하고 시계 표본을 생략한다', async (t) => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'https://example.test' } }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { cookie: `${WEB_AUTH_CSRF_COOKIE_KEY}=confirmed` }, + }); + t.after(() => { + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, 'document'); + }); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') + return { + url: 'data:text/javascript,export const env = {KOIN_ENVIRONMENT:"preview",KOIN_API_ORIGIN:"https://api.stage.koreatech.in"}', + shortCircuit: true, + }; + return nextResolve(specifier, context); + }, + }); + t.after(() => hooks.deregister()); + // APIClient의 브라우저 판별과 Cloudflare 모듈 대체를 먼저 설치한다. + const { getClientRootSnapshot } = await import('../src/utils/ssr/pageLoader'); + const { initializeSessionConfirmation } = await import('../src/utils/auth/sessionConfirmation'); + const { SESSION_QUERY_KEY } = await import('../src/utils/auth/session'); + const { queryClient } = await import('../src/utils/ts/queryClient'); + initializeSessionConfirmation(true); + t.after(() => queryClient.clear()); + queryClient.setQueryData(SESSION_QUERY_KEY, { status: 'authenticated', userType: 'STUDENT' }); + await queryClient.invalidateQueries({ queryKey: SESSION_QUERY_KEY, refetchType: 'none' }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + queryClient.setQueryData(SESSION_QUERY_KEY, { status: 'anonymous' }); + throw new Error('offline'); + }); + const now = new Date(performance.timeOrigin + 10_000).toISOString(); + const result = await getClientRootSnapshot({ + device: 'desktop', + isLoggedIn: true, + userType: 'STUDENT', + now, + mayBeSharedCached: true, + }); + assert.equal(calls, 1); + assert.equal(result.serverRequest.userType, null); + assert.ok(Math.abs(Date.parse(result.serverRequest.now) - (Date.now() + 10_000)) < 1000); +}); diff --git a/apps/web/test/page-loader-session.test.ts b/apps/web/test/page-loader-session.test.ts new file mode 100644 index 00000000..0d0c1fcb --- /dev/null +++ b/apps/web/test/page-loader-session.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; +import { WEB_AUTH_CSRF_COOKIE_KEY } from '../src/static/url'; + +test('세션 조회 실패 시 확인된 쿠키의 캐시만 유지한다', async (t) => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { origin: 'https://example.test' } }, + }); + const document = { cookie: `${WEB_AUTH_CSRF_COOKIE_KEY}=confirmed` }; + Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); + t.after(() => { + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, 'document'); + }); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') { + return { + url: 'data:text/javascript,export const env = {KOIN_ENVIRONMENT:"preview",KOIN_API_ORIGIN:"https://api.stage.koreatech.in"}', + shortCircuit: true, + }; + } + return nextResolve(specifier, context); + }, + }); + t.after(() => hooks.deregister()); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('{}', { status: 500, headers: { Date: new Date().toUTCString() } }), + ); + // APIClient가 로딩 시 브라우저 여부를 결정하므로 브라우저 전역을 설치한 뒤 가져온다. + const { getClientRootSnapshot } = await import('../src/utils/ssr/pageLoader'); + const { initializeSessionConfirmation } = await import('../src/utils/auth/sessionConfirmation'); + const { SESSION_QUERY_KEY } = await import('../src/utils/auth/session'); + const { queryClient } = await import('../src/utils/ts/queryClient'); + initializeSessionConfirmation(true); + t.after(() => queryClient.clear()); + queryClient.setQueryData(SESSION_QUERY_KEY, { status: 'authenticated', userType: 'STUDENT' }); + await queryClient.invalidateQueries({ queryKey: SESSION_QUERY_KEY, refetchType: 'none' }); + const input = { + device: 'desktop', + isLoggedIn: true, + userType: 'STUDENT', + now: new Date().toISOString(), + mayBeSharedCached: false, + } as const; + const confirmed = await getClientRootSnapshot(input); + assert.equal(confirmed.serverRequest.userType, 'STUDENT'); + document.cookie = `${WEB_AUTH_CSRF_COOKIE_KEY}=other`; + const unconfirmed = await getClientRootSnapshot(input); + assert.equal(unconfirmed.serverRequest.userType, null); +}); diff --git a/apps/web/test/server-clock-ssr.test.ts b/apps/web/test/server-clock-ssr.test.ts new file mode 100644 index 00000000..a397f3a8 --- /dev/null +++ b/apps/web/test/server-clock-ssr.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as clock from '../src/utils/ssr/serverClock'; + +test('SSR 시각 기준 오프셋과 Date 표본은 1초 미만의 후퇴를 무시한다', async (t) => { + const now = Math.floor(Date.now() / 1000) * 1000; + t.mock.method(Date, 'now', () => now); + clock.initializeServerClock(new Date(performance.timeOrigin + 900).toISOString(), false); + clock.sampleServerClock(new Response(null, { headers: { Date: new Date(now).toUTCString() } })); + const read = () => + clock.getFreshServerNow(async () => { + throw new Error('unexpected request'); + }); + assert.ok(Math.abs(Date.parse(await read()) - (now + 900)) <= 1); + clock.sampleServerClock(new Response(null, { headers: { Date: new Date(now + 2000).toUTCString() } })); + assert.equal(Date.parse(await read()), now + 2500); + clock.sampleServerClock(new Response(null, { headers: { Date: new Date(now + 1000).toUTCString() } })); + assert.equal(Date.parse(await read()), now + 1500); + clock.sampleServerClock(new Response(null, { headers: { Date: new Date(now).toUTCString() } })); + assert.equal(Date.parse(await read()), now + 500); +}); diff --git a/apps/web/test/server-clock.test.ts b/apps/web/test/server-clock.test.ts new file mode 100644 index 00000000..497be717 --- /dev/null +++ b/apps/web/test/server-clock.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import * as clock from '../src/utils/ssr/serverClock'; + +test('공유 문서의 실패한 표본은 이동에만 사용하고 다음 이동에서 다시 조회한다', async (t) => { + const now = Date.now(); + t.mock.method(Date, 'now', () => now); + clock.initializeServerClock(new Date(performance.timeOrigin + 10_000).toISOString(), true); + let calls = 0; + const fail = async () => { + calls++; + throw new Error('offline'); + }; + const [first, concurrent] = await Promise.all([clock.getFreshServerNow(fail), clock.getFreshServerNow(fail)]); + assert.equal(calls, 1); + assert.ok(Math.abs(Date.parse(first) - (now + 10_000)) < 1000); + assert.equal(first, concurrent); + await clock.getFreshServerNow(async () => { + calls++; + clock.sampleServerClock(new Response(null, { status: 403, headers: { Date: new Date(now).toUTCString() } })); + throw new Error('forbidden'); + }); + assert.equal(calls, 2); + await clock.getFreshServerNow(fail); + assert.equal(calls, 2); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4fbea1ec..f38ecce9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,6 +21,6 @@ }, "incremental": true }, - "include": ["src/**/*", "vite.config.ts", "worker-configuration.d.ts"], + "include": ["src/**/*", "test/**/*", "vite.config.ts", "worker-configuration.d.ts"], "exclude": ["node_modules", ".output", "dist", ".yarn"] } From b3f16c3257292366a92d378d83bbf300ef324edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunsup=20Sim=28=EC=8B=AC=EC=9C=A4=EC=84=AD=29?= Date: Wed, 11 Nov 2026 17:25:21 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=9D=B8=ED=84=B0=EB=84=90=20?= =?UTF-8?q?=EC=B6=9C=EA=B7=BC=EB=B6=80=20Worker=EC=99=80=20=EB=9D=BC?= =?UTF-8?q?=EC=A6=88=EB=B2=A0=EB=A6=AC=ED=8C=8C=EC=9D=B4=203B=20=EB=A6=AC?= =?UTF-8?q?=EB=8D=94=20=EC=B6=94=EA=B0=80=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인터널 출근부 Worker와 Arduino·라즈베리파이 리더 추가 * feat: KOREATECH 앱 학번 리더 추가와 퇴근 미기록 세션 처리 * refactor: 출근부에서 Arduino 리더 코드를 제거한다 * feat: 출근부 라즈베리파이 3B에 RC522 직결 리더와 LED 표시를 추가한다 --- .gitignore | 2 +- README.md | 1 + apps/attendance/README.md | 110 ++++++ apps/attendance/package.json | 11 + apps/attendance/pi/attendance-reader.service | 17 + apps/attendance/pi/reader.py | 368 +++++++++++++++++++ apps/attendance/pi/wiring.png | Bin 0 -> 200307 bytes apps/attendance/src/dashboard.ts | 226 ++++++++++++ apps/attendance/src/index.ts | 271 ++++++++++++++ apps/attendance/src/logic.ts | 137 +++++++ apps/attendance/test/logic.test.mjs | 89 +++++ apps/attendance/tsconfig.json | 15 + apps/attendance/wrangler.jsonc | 11 + package.json | 2 + 14 files changed, 1259 insertions(+), 1 deletion(-) create mode 100644 apps/attendance/README.md create mode 100644 apps/attendance/package.json create mode 100644 apps/attendance/pi/attendance-reader.service create mode 100755 apps/attendance/pi/reader.py create mode 100644 apps/attendance/pi/wiring.png create mode 100644 apps/attendance/src/dashboard.ts create mode 100644 apps/attendance/src/index.ts create mode 100644 apps/attendance/src/logic.ts create mode 100644 apps/attendance/test/logic.test.mjs create mode 100644 apps/attendance/tsconfig.json create mode 100644 apps/attendance/wrangler.jsonc diff --git a/.gitignore b/.gitignore index 56e9149c..92853df2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ apps/b-bot-crawler/data/ apps/api/scripts/source-sync-uploader.py apps/api/scripts/source-sync-cron.sh apps/api/scripts/source-sync-mysql-prep.py -apps/api/scripts/__pycache__/ +__pycache__/ diff --git a/README.md b/README.md index d93c0e92..6437fa13 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ apps/ web/ TanStack Start 프론트엔드·SSR Worker api/ Hono 업무 API Worker (레거시, 기본 비활성) b-bot/ Slack 봇 Worker + attendance/ 출근부: 카드 태그 기록 Worker·대시보드, 라즈베리파이 리더 error-automation/ 오류 웹훅 수신과 자동 수정 작업 시작 packages/ telemetry/ 공통 민감정보 마스킹 diff --git a/apps/attendance/README.md b/apps/attendance/README.md new file mode 100644 index 00000000..e2e1af08 --- /dev/null +++ b/apps/attendance/README.md @@ -0,0 +1,110 @@ +# 출근부 (attendance) + +인터널 출입 카드를 리더로 태그하면 입실/퇴실을 기록하고, 대시보드에 현재 인원과 체류 시간을 보여 주는 Worker입니다. 저장소는 Workers KV입니다. + +```text +RC522(SPI) 또는 USB HID 리더 ──▶ 라즈베리파이 3B(pi/reader.py) ──POST /api/taps──▶ Worker + KV ──▶ 대시보드(/) +``` + +## 동작 규칙 + +- 태그는 토글입니다. 열린 세션이 없으면 입실, 있으면 퇴실입니다. +- 같은 회원의 직전 이벤트 후 10초 안의 태그(카드 중복 인식)와, 직전 이벤트보다 이른 재전송은 무시합니다. +- 퇴근 태그가 없는 세션은 입실 후 6시간(`MAX_SESSION_MS`)이 지나면 입실 중으로 보지 않습니다. 같은 회원의 다음 태그가 6시간 시점에 그 세션을 자동 종료(`autoClosed`)하고 새 입실을 만듭니다. 이런 세션은 실제 체류 시간을 알 수 없으므로 오늘·이번 주 시간 합계에 넣지 않고, 대시보드에 "퇴근 미기록"으로 건수를 표시합니다. +- 오늘·이번 주(월요일 시작) 경계는 KST입니다. 자정을 걸친 세션은 날짜별로 나눠 합산합니다. +- 등록되지 않은 카드를 태그하면 `unknown:`에 쌓이고, 대시보드의 "미등록 카드"에서 이름을 입력해 등록합니다. 같은 카드로 다시 등록하면 이름만 바뀝니다. 등록하지 않은 카드는 7일 뒤 자동 삭제됩니다. + +## 저장 구조 (KV) + +데이터는 모두 키 메타데이터에 두어 대시보드가 `list` 호출만으로 읽습니다. + +| 키 | 내용 | +| --------------------------------------- | ------------------------------------------------------- | +| `member:` | 메타 `{ name }` | +| `unknown:` | 메타 `{ firstSeen, lastSeen, count }` | +| `s::<입실 시각 13자리>:` | 세션. 메타 `{ checkOut, autoClosed }` | +| `latest:` | 값 `{ checkIn, checkOut, autoClosed }` 최근 세션 포인터 | + +KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60초 늦게 보입니다. 리더 한 곳에서 태그하는 용도라 태그 판정은 같은 지역의 읽기-쓰기에 의존하며, 대시보드 수치는 그만큼 지연될 수 있습니다. 같은 카드의 거의 동시 태그는 10초 디바운스가 막습니다. + +## API + +| 경로 | 인증 | 용도 | +| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | +| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | +| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | +| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | + +## 배포 + +```sh +pnpm --filter @koin/attendance exec wrangler secret put DEVICE_TOKEN +pnpm --filter @koin/attendance exec wrangler secret put DASHBOARD_PASSWORD +pnpm deploy:attendance +``` + +KV 네임스페이스는 첫 배포 때 wrangler가 만들고 `id`를 기록합니다. 기록된 `wrangler.jsonc`를 커밋합니다. 토큰은 충분히 긴 무작위 값을 사용합니다(`openssl rand -hex 32`). + +벽걸이 화면은 `https://:@/` 로 열면 인증 창 없이 표시됩니다. + +## 로컬 실행 + +```sh +printf 'DEVICE_TOKEN=dev-device-token\nDASHBOARD_PASSWORD=dev-password\n' > apps/attendance/.dev.vars +pnpm dev:attendance # http://localhost:8787 +curl -X POST localhost:8787/api/taps -H 'authorization: Bearer dev-device-token' \ + -H 'content-type: application/json' -d '{"uid":"04A1B2C3"}' +``` + +## 라즈베리파이 + +Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. + +- `rc522`: GPIO(SPI)에 직접 연결한 RC522 모듈. 카드 UID를 16진수 대문자(예: `04A1B2C3`)로 보내고, 결과를 LED 두 개로 알립니다. +- `hid`(기본값): 카드 UID를 키 입력 + Enter로 보내는 USB HID 리더. 장치를 독점해 UID가 콘솔에 입력되지 않으며, 장치가 여러 개면 `READER_DEVICE`로 경로를 지정합니다. + +같은 카드라도 리더 종류에 따라 UID 문자열이 다를 수 있으므로, 리더를 바꾸면 회원을 다시 등록합니다. + +### RC522 배선 + +![Raspberry Pi 3B + RC522 + LED 배선](pi/wiring.png) + +| 부품 | 연결 | Pi 물리 핀(BCM) | +| ------------ | ----------------------------------- | --------------- | +| RC522 SDA | SPI CE0 | 24 (GPIO8) | +| RC522 SCK | SPI SCLK | 23 (GPIO11) | +| RC522 MOSI | SPI MOSI | 19 (GPIO10) | +| RC522 MISO | SPI MISO | 21 (GPIO9) | +| RC522 IRQ | 연결 안 함 | - | +| RC522 GND | GND | 25 | +| RC522 RST | 리셋 | 22 (GPIO25) | +| RC522 3.3V | 3.3V 전원(5V 연결 금지) | 1 | +| 초록 LED | GPIO → 330 Ω → 애노드, 캐소드 → GND | 16 (GPIO23) | +| 빨강 LED | GPIO → 330 Ω → 애노드, 캐소드 → GND | 18 (GPIO24) | +| LED 공통 GND | GND | 20 | + +| 결과 | LED | +| --------------------------- | ------------- | +| 입실 | 초록 1초 | +| 퇴실 | 초록 2번 깜빡 | +| 10초 안 중복(무시) | 초록 짧게 1번 | +| 미등록 카드 | 빨강 1초 | +| 오프라인·서버 오류(큐 보관) | 빨강 2번 깜빡 | +| 거절(잘못된 UID 등) | 빨강 3번 깜빡 | + +시작할 때 초록·빨강이 차례로 한 번 켜집니다. 카드는 읽은 뒤 HALT시키므로 떼었다 다시 대야 다시 읽힙니다. + +### 설치 + +```sh +sudo raspi-config nonint do_spi 0 # rc522: SPI 활성화(/dev/spidev0.0) +sudo apt install python3-spidev python3-rpi.gpio # rc522. hid는 python3-evdev +sudo install -D pi/reader.py /opt/attendance/reader.py +sudo install -m 600 /dev/null /etc/attendance-reader.env # ATTENDANCE_URL=…, DEVICE_TOKEN=…, READER=rc522 +sudo install pi/attendance-reader.service /etc/systemd/system/ +sudo systemctl enable --now attendance-reader +``` + +- 오프라인이면 태그를 `/var/lib/attendance/queue.jsonl`에 보관하고 30초마다 순서대로 재전송합니다. 태그 시각(`at`)은 파이 시계 기준이므로 NTP 동기화가 필요합니다. +- 인증 오류(401/403)·408·429·5xx는 큐에 남겨 재시도하고, 그 외 4xx(잘못된 UID 등)는 버립니다. diff --git a/apps/attendance/package.json b/apps/attendance/package.json new file mode 100644 index 00000000..2707b15f --- /dev/null +++ b/apps/attendance/package.json @@ -0,0 +1,11 @@ +{ + "name": "@koin/attendance", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --experimental-strip-types --test test/logic.test.mjs", + "deploy": "wrangler deploy" + } +} diff --git a/apps/attendance/pi/attendance-reader.service b/apps/attendance/pi/attendance-reader.service new file mode 100644 index 00000000..9b7ae067 --- /dev/null +++ b/apps/attendance/pi/attendance-reader.service @@ -0,0 +1,17 @@ +[Unit] +Description=BCSD attendance RFID reader +After=network-online.target +Wants=network-online.target + +[Service] +# ATTENDANCE_URL, DEVICE_TOKEN(선택: READER, READER_DEVICE, QUEUE_FILE)은 이 파일에 둔다. 권한은 600. +EnvironmentFile=/etc/attendance-reader.env +ExecStart=/usr/bin/python3 /opt/attendance/reader.py +StateDirectory=attendance +Restart=always +RestartSec=5 +# 입력 장치(/dev/input)·SPI(/dev/spidev0.0)·GPIO 접근을 위해 root로 실행한다. +User=root + +[Install] +WantedBy=multi-user.target diff --git a/apps/attendance/pi/reader.py b/apps/attendance/pi/reader.py new file mode 100755 index 00000000..897400cd --- /dev/null +++ b/apps/attendance/pi/reader.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""RFID 리더의 태그를 출근부 Worker로 전송한다. + +리더 종류(READER): + rc522 Raspberry Pi GPIO(SPI)에 직접 연결한 RC522(MFRC522) 모듈. 카드 UID를 16진수 대문자로 보낸다. + 태그 결과를 LED 두 개(초록·빨강)로 알린다. 배선은 ../README.md 의 "라즈베리파이" 절. + hid (기본값) 카드 UID를 숫자·영문 키 입력 후 Enter로 보내는 일반 USB 리더. 장치를 독점(grab)해 + UID가 콘솔 입력으로 새지 않게 한다. +네트워크가 끊기면 태그를 QUEUE_FILE에 쌓았다가 다음 태그·유휴 주기에 순서대로 재전송한다. + +환경 변수: + ATTENDANCE_URL 예: https://bcsd-attendance..workers.dev + DEVICE_TOKEN Worker의 DEVICE_TOKEN 시크릿과 같은 값 + READER rc522 | hid (기본 hid) + READER_DEVICE (hid) USB HID 입력 장치 경로. 기본값은 /dev/input/by-id/ 의 첫 *-event-kbd + QUEUE_FILE 기본값 /var/lib/attendance/queue.jsonl +""" + +import glob +import json +import os +import select +import sys +import time +import urllib.error +import urllib.request + +try: + from evdev import InputDevice, ecodes +except ImportError: + InputDevice = ecodes = None + +FLUSH_INTERVAL_SECONDS = 30 +REQUEST_TIMEOUT_SECONDS = 10 +# 한 번 태그로 인정하는 UID 문자 범위. 서버의 UID_PATTERN과 같아야 한다. +MIN_UID_LENGTH = 4 +MAX_UID_LENGTH = 32 + +# rc522 배선(BCM 번호). SPI0 CE0(GPIO8)·SCK(11)·MOSI(10)·MISO(9)는 spidev가 쓴다. +RC522_RST_PIN = 25 +GREEN_LED_PIN = 23 +RED_LED_PIN = 24 +POLL_INTERVAL_SECONDS = 0.1 +# 카드를 떼었다 다시 댄 같은 카드를 한 번으로 보는 시간. 서버도 10초 안의 중복 태그를 무시한다. +REPEAT_GUARD_SECONDS = 2.0 + + +def build_keymap(): + keymap = {} + for name, code in ecodes.ecodes.items(): + if not isinstance(name, str) or not name.startswith("KEY_"): + continue + suffix = name[4:] + if len(suffix) == 1 and suffix.isalnum(): + keymap[code] = suffix + elif suffix.startswith("KP") and len(suffix) == 3 and suffix[2].isdigit(): + keymap[code] = suffix[2] + return keymap + + +def find_device_path(): + explicit = os.environ.get("READER_DEVICE") + if explicit: + return explicit + candidates = sorted(glob.glob("/dev/input/by-id/*-event-kbd")) + if not candidates: + sys.exit("리더 장치를 찾지 못했습니다. READER_DEVICE로 경로를 지정하세요.") + return candidates[0] + + +def read_queue(path): + try: + with open(path, encoding="utf-8") as handle: + return [json.loads(line) for line in handle if line.strip()] + except FileNotFoundError: + return [] + + +def write_queue(path, entries): + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary = path + ".tmp" + with open(temporary, "w", encoding="utf-8") as handle: + for entry in entries: + handle.write(json.dumps(entry) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + + +def send(url, token, entry): + """서버 처리 결과(응답의 status, 영구 거절이면 "rejected")를 돌려준다. 나중에 재시도해야 하면 None.""" + request = urllib.request.Request( + url.rstrip("/") + "/api/taps", + data=json.dumps(entry).encode(), + headers={"authorization": "Bearer " + token, "content-type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + body = response.read().decode() + print(entry["uid"], response.status, body, flush=True) + try: + return json.loads(body).get("status") or "ok" + except (ValueError, AttributeError): + return "ok" + except urllib.error.HTTPError as error: + print(entry["uid"], error.code, error.read().decode(), flush=True) + # 인증 오류·서버 오류·속도 제한은 설정/복구 후 다시 보내야 하므로 큐에 남긴다. + if error.code in (401, 403, 408, 429) or error.code >= 500: + return None + return "rejected" + except (urllib.error.URLError, TimeoutError, OSError) as error: + print("전송 실패, 큐에 보관:", error, flush=True) + return None + + +def flush_queue(url, token, queue_file): + """큐를 앞에서부터 보낸다. 큐를 모두 비웠으면 마지막 항목의 처리 결과, 아니면 None.""" + entries = read_queue(queue_file) + if not entries: + return None + remaining = list(entries) + outcome = None + while remaining: + outcome = send(url, token, remaining[0]) + if outcome is None: + break + remaining.pop(0) + if len(remaining) != len(entries): + write_queue(queue_file, remaining) + return None if remaining else outcome + + +def record_tap(url, token, queue_file, uid): + """태그를 큐에 넣고 바로 전송한다. 이 태그의 처리 결과, 전송하지 못했으면 "queued".""" + if not MIN_UID_LENGTH <= len(uid) <= MAX_UID_LENGTH: + print("무시한 입력 길이:", len(uid), flush=True) + return "rejected" + entries = read_queue(queue_file) + entries.append({"uid": uid, "at": int(time.time() * 1000)}) + write_queue(queue_file, entries) + return flush_queue(url, token, queue_file) or "queued" + + +def run_keyboard(url, token, queue_file): + if InputDevice is None: + sys.exit("python3-evdev가 필요합니다: sudo apt install python3-evdev") + keymap = build_keymap() + device = InputDevice(find_device_path()) + device.grab() + print("리더 대기 중:", device.name, flush=True) + + buffer = "" + last_flush = time.monotonic() + while True: + readable, _, _ = select.select([device.fd], [], [], FLUSH_INTERVAL_SECONDS) + if not readable: + flush_queue(url, token, queue_file) + last_flush = time.monotonic() + continue + for event in device.read(): + if event.type != ecodes.EV_KEY or event.value != 1: # 키 누름만 처리 + continue + if event.code in (ecodes.KEY_ENTER, ecodes.KEY_KPENTER): + uid, buffer = buffer, "" + if uid: + record_tap(url, token, queue_file, uid) + last_flush = time.monotonic() + elif event.code in keymap: + buffer += keymap[event.code] + if time.monotonic() - last_flush >= FLUSH_INTERVAL_SECONDS: + flush_queue(url, token, queue_file) + last_flush = time.monotonic() + + +class Mfrc522: + """spidev로 MFRC522를 직접 다루는 최소 구현. ISO 14443A 카드 하나의 UID(4·7·10바이트)만 읽는다.""" + + COMMAND, COM_IRQ, DIV_IRQ, ERROR, FIFO_DATA, FIFO_LEVEL, CONTROL, BIT_FRAMING, COLL = ( + 0x01, 0x04, 0x05, 0x06, 0x09, 0x0A, 0x0C, 0x0D, 0x0E) + MODE, TX_MODE, RX_MODE, TX_CONTROL, TX_ASK = 0x11, 0x12, 0x13, 0x14, 0x15 + CRC_RESULT_H, CRC_RESULT_L, MOD_WIDTH, RF_CFG = 0x21, 0x22, 0x24, 0x26 + T_MODE, T_PRESCALER, T_RELOAD_H, T_RELOAD_L, VERSION = 0x2A, 0x2B, 0x2C, 0x2D, 0x37 + IDLE, CALC_CRC, TRANSCEIVE = 0x00, 0x03, 0x0C + # 카드 응답 대기 상한. 칩 타이머(25 ms)가 먼저 끝나는 것이 정상이다. + WAIT_SECONDS = 0.05 + + def __init__(self, spi, gpio, rst_pin): + spi.open(0, 0) + spi.max_speed_hz = 1_000_000 + spi.mode = 0 + self.spi = spi + gpio.setup(rst_pin, gpio.OUT) + gpio.output(rst_pin, gpio.LOW) + time.sleep(0.01) + gpio.output(rst_pin, gpio.HIGH) + time.sleep(0.05) + self.version = self._read(self.VERSION) + if self.version in (0x00, 0xFF): + raise RuntimeError("RC522 응답 없음(배선·SPI 활성화 확인)") + self._write(self.TX_MODE, 0x00) + self._write(self.RX_MODE, 0x00) + self._write(self.MOD_WIDTH, 0x26) + # 타이머: 전송이 끝나면 자동 시작, 약 25 ms 뒤 응답 없음으로 본다. + self._write(self.T_MODE, 0x80) + self._write(self.T_PRESCALER, 0xA9) + self._write(self.T_RELOAD_H, 0x03) + self._write(self.T_RELOAD_L, 0xE8) + self._write(self.TX_ASK, 0x40) # 100% ASK + self._write(self.MODE, 0x3D) # CRC 초기값 0x6363(ISO 14443A) + self._write(self.RF_CFG, 0x70) # 수신 이득 최대(48 dB): 케이스 벽 너머로도 읽히게 + self._set_bits(self.TX_CONTROL, 0x03) # 안테나 켬 + + def _write(self, reg, *values): + self.spi.xfer2([(reg << 1) & 0x7E, *values]) + + def _read(self, reg): + return self.spi.xfer2([((reg << 1) & 0x7E) | 0x80, 0])[1] + + def _set_bits(self, reg, mask): + self._write(reg, self._read(reg) | mask) + + def _clear_bits(self, reg, mask): + self._write(reg, self._read(reg) & ~mask & 0xFF) + + def _transceive(self, data, tx_last_bits=0): + """카드와 프레임을 주고받는다. 응답 바이트 목록, 응답이 없거나 오류(충돌 포함)면 None.""" + self._write(self.COMMAND, self.IDLE) + self._write(self.COM_IRQ, 0x7F) + self._write(self.FIFO_LEVEL, 0x80) + self._write(self.FIFO_DATA, *data) + self._write(self.BIT_FRAMING, tx_last_bits) + self._write(self.COMMAND, self.TRANSCEIVE) + self._set_bits(self.BIT_FRAMING, 0x80) # StartSend + deadline = time.monotonic() + self.WAIT_SECONDS + try: + while True: + irq = self._read(self.COM_IRQ) + if irq & 0x30: # RxIRq | IdleIRq + break + if irq & 0x01 or time.monotonic() > deadline: # TimerIRq + return None + finally: + self._clear_bits(self.BIT_FRAMING, 0x80) + if self._read(self.ERROR) & 0x1B: # BufferOvfl | CollErr | ParityErr | ProtocolErr + return None + return [self._read(self.FIFO_DATA) for _ in range(self._read(self.FIFO_LEVEL))] + + def _crc(self, data): + self._write(self.COMMAND, self.IDLE) + self._write(self.DIV_IRQ, 0x04) + self._write(self.FIFO_LEVEL, 0x80) + self._write(self.FIFO_DATA, *data) + self._write(self.COMMAND, self.CALC_CRC) + deadline = time.monotonic() + self.WAIT_SECONDS + while not self._read(self.DIV_IRQ) & 0x04: + if time.monotonic() > deadline: + raise RuntimeError("RC522 CRC 계산 시간 초과") + self._write(self.COMMAND, self.IDLE) + return [self._read(self.CRC_RESULT_L), self._read(self.CRC_RESULT_H)] + + def read_uid(self): + """대기(IDLE) 상태 카드 하나의 UID를 16진수 대문자로 읽고 HALT시킨다. 없으면 None. + + HALT된 카드는 REQA에 답하지 않으므로, 카드를 떼었다 다시 대야 다시 읽힌다. + """ + self._clear_bits(self.COLL, 0x80) + atqa = self._transceive([0x26], tx_last_bits=7) # REQA(7비트) + if not atqa or len(atqa) != 2: + return None + uid = [] + for cascade in (0x93, 0x95, 0x97): + part = self._transceive([cascade, 0x20]) # ANTICOLLISION: UID 4바이트 + BCC + if not part or len(part) != 5 or part[0] ^ part[1] ^ part[2] ^ part[3] != part[4]: + return None + frame = [cascade, 0x70, *part] # SELECT + sak = self._transceive(frame + self._crc(frame)) + if not sak or len(sak) != 3: + return None + if sak[0] & 0x04: # UID가 더 남음: 첫 바이트는 캐스케이드 태그(0x88) + uid += part[1:4] + continue + uid += part[0:4] + halt = [0x50, 0x00] + self._transceive(halt + self._crc(halt)) # 응답이 없는 것이 정상 + return bytes(uid).hex().upper() + return None + + +class Feedback: + """태그 결과를 초록·빨강 LED로 알린다.""" + + # 결과 → (LED 핀, 켜짐 초, 꺼짐 초, 횟수) + PATTERNS = { + "check-in": (GREEN_LED_PIN, 1.0, 0.0, 1), + "check-out": (GREEN_LED_PIN, 0.25, 0.2, 2), + "ignored": (GREEN_LED_PIN, 0.15, 0.0, 1), + "unknown": (RED_LED_PIN, 1.0, 0.0, 1), + "queued": (RED_LED_PIN, 0.25, 0.2, 2), + "rejected": (RED_LED_PIN, 0.15, 0.15, 3), + } + + def __init__(self, gpio): + self.gpio = gpio + for pin in (GREEN_LED_PIN, RED_LED_PIN): + gpio.setup(pin, gpio.OUT, initial=gpio.LOW) + + def _pulse(self, pin, on, off=0.0, times=1): + for i in range(times): + self.gpio.output(pin, self.gpio.HIGH) + time.sleep(on) + self.gpio.output(pin, self.gpio.LOW) + if i < times - 1: + time.sleep(off) + + def startup(self): + self._pulse(GREEN_LED_PIN, 0.3) + self._pulse(RED_LED_PIN, 0.3) + + def result(self, outcome): + self._pulse(*self.PATTERNS.get(outcome, self.PATTERNS["rejected"])) + + +def run_rc522(url, token, queue_file): + try: + import RPi.GPIO as GPIO + import spidev + except ImportError: + sys.exit("python3-spidev와 python3-rpi.gpio(또는 python3-rpi-lgpio)가 필요합니다") + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + try: + feedback = Feedback(GPIO) + reader = Mfrc522(spidev.SpiDev(), GPIO, RC522_RST_PIN) + print(f"RC522 대기 중: 버전 0x{reader.version:02X}", flush=True) + feedback.startup() + last_uid, last_at = None, 0.0 + last_flush = time.monotonic() + while True: + uid = reader.read_uid() + now = time.monotonic() + if uid and not (uid == last_uid and now - last_at < REPEAT_GUARD_SECONDS): + last_uid, last_at = uid, now + feedback.result(record_tap(url, token, queue_file, uid)) + last_flush = time.monotonic() + elif now - last_flush >= FLUSH_INTERVAL_SECONDS: + flush_queue(url, token, queue_file) + last_flush = time.monotonic() + time.sleep(POLL_INTERVAL_SECONDS) + finally: + GPIO.cleanup() + + +def main(): + url = os.environ["ATTENDANCE_URL"] + token = os.environ["DEVICE_TOKEN"] + queue_file = os.environ.get("QUEUE_FILE", "/var/lib/attendance/queue.jsonl") + reader = os.environ.get("READER", "hid") + if reader == "rc522": + run_rc522(url, token, queue_file) + elif reader == "hid": + run_keyboard(url, token, queue_file) + else: + sys.exit(f"알 수 없는 READER: {reader} (rc522 | hid)") + + +if __name__ == "__main__": + main() diff --git a/apps/attendance/pi/wiring.png b/apps/attendance/pi/wiring.png new file mode 100644 index 0000000000000000000000000000000000000000..5c9d71b9262f6d55aca96b9d821a531778a94a74 GIT binary patch literal 200307 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%0A=FXN)gzk|Q-;dJ8p(3Et?S2_ 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 + + + + + +BCSD 출근부 + + + +

+
+

BCSD 출근부

+
+
+ +
+

지금 있는 사람

+
+ +
+
+

이번 주 체류 시간 (월요일 시작, KST)

+ + + +
이름상태오늘 첫 입실오늘이번 주퇴근 미기록
+
+ +
+

최근 기록

+ + + +
이름입실퇴실체류
+
+
+ + + +`; diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts new file mode 100644 index 00000000..359b5ffc --- /dev/null +++ b/apps/attendance/src/index.ts @@ -0,0 +1,271 @@ +import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic'; +import { DASHBOARD_HTML } from './dashboard'; + +interface Env { + /** + * 키 구성 (값은 비어 있고 데이터는 모두 메타데이터에 두어 list 한 번으로 읽습니다): + * - `member:` 메타 {name} + * - `unknown:` 메타 {firstSeen, lastSeen, count} + * - `s::<입실 시각 13자리>:` 메타 {checkOut, autoClosed} — 세션 + * - `latest:` 값 JSON {checkIn, checkOut, autoClosed} — 회원의 최근 세션 포인터 + */ + ATTENDANCE: KVNamespace; + /** 라즈베리파이가 태그 이벤트를 보낼 때 쓰는 Bearer 토큰. */ + DEVICE_TOKEN: string; + /** 대시보드·회원 등록용 Basic 인증 비밀번호(사용자 이름은 무시). */ + DASHBOARD_PASSWORD: string; +} + +interface MemberMeta { + name: string; +} + +interface UnknownMeta { + firstSeen: number; + lastSeen: number; + count: number; +} + +interface SessionMeta { + checkOut: number | null; + autoClosed: boolean; +} + +interface LatestSession extends SessionMeta { + checkIn: number; +} + +const UID_PATTERN = /^[0-9A-Z]{4,32}$/; +const MAX_CLOCK_SKEW_MS = 60_000; +const MAX_OFFLINE_REPLAY_MS = 7 * DAY_MS; +const RECENT_LIMIT = 15; +const UNKNOWN_TTL_SECONDS = 7 * 24 * 3600; +const SECURITY_HEADERS = { + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', +}; +const UNAUTHORIZED = { + status: 401, + headers: { ...SECURITY_HEADERS, 'www-authenticate': 'Basic realm="BCSD attendance", charset="UTF-8"' }, +} as const; +const encoder = new TextEncoder(); + +function json(body: unknown, status = 200, headers: HeadersInit = {}): Response { + return Response.json(body, { status, headers: { ...SECURITY_HEADERS, ...headers } }); +} + +/** 길이·내용 모두 시간 차이 없이 비교하도록 SHA-256 다이제스트끼리 비교합니다. */ +async function safeEqual(actual: string, expected: string): Promise { + if (!expected) return false; + const [a, b] = await Promise.all([ + crypto.subtle.digest('SHA-256', encoder.encode(actual)), + crypto.subtle.digest('SHA-256', encoder.encode(expected)), + ]); + return crypto.subtle.timingSafeEqual(a, b); +} + +async function deviceAuthorized(request: Request, env: Env): Promise { + const header = request.headers.get('authorization') ?? ''; + return header.startsWith('Bearer ') && safeEqual(header.slice(7), env.DEVICE_TOKEN); +} + +async function dashboardAuthorized(request: Request, env: Env): Promise { + const header = request.headers.get('authorization') ?? ''; + if (!header.startsWith('Basic ')) return false; + let decoded: string; + try { + decoded = atob(header.slice(6)); + } catch { + return false; + } + const separator = decoded.indexOf(':'); + return separator >= 0 && safeEqual(decoded.slice(separator + 1), env.DASHBOARD_PASSWORD); +} + +/** 브라우저가 Basic 자격증명을 자동 첨부하므로 mutation은 같은 출처의 JSON 요청만 받습니다. */ +function sameOriginJson(request: Request, url: URL): boolean { + const origin = request.headers.get('origin'); + if (origin !== null && origin !== url.origin) return false; + return (request.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'); +} + +async function readJson(request: Request): Promise | null> { + try { + const body: unknown = await request.json(); + return body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record) : null; + } catch { + return null; + } +} + +function sessionKey(uid: string, checkIn: number): string { + return `s:${kstDateKey(checkIn)}:${String(checkIn).padStart(13, '0')}:${uid}`; +} + +/** 접두사 아래 모든 키를 메타데이터와 함께 읽습니다(list는 페이지당 최대 1000개). */ +async function listAll(kv: KVNamespace, prefix: string): Promise<{ name: string; metadata: TMeta }[]> { + const result: { name: string; metadata: TMeta }[] = []; + let cursor: string | undefined; + do { + const page = await kv.list({ prefix, cursor }); + for (const key of page.keys) if (key.metadata) result.push({ name: key.name, metadata: key.metadata }); + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); + return result; +} + +async function handleTap(request: Request, env: Env): Promise { + if (!(await deviceAuthorized(request, env))) return json({ error: 'unauthorized' }, 401); + const body = await readJson(request); + if (!body || typeof body.uid !== 'string') return json({ error: 'invalid_body' }, 400); + + const uid = body.uid.trim().toUpperCase(); + if (!UID_PATTERN.test(uid)) return json({ error: 'invalid_uid' }, 400); + + const now = Date.now(); + const at = body.at === undefined ? now : body.at; + if ( + typeof at !== 'number' || + !Number.isSafeInteger(at) || + at > now + MAX_CLOCK_SKEW_MS || + at < now - MAX_OFFLINE_REPLAY_MS + ) { + return json({ error: 'invalid_time' }, 400); + } + + const kv = env.ATTENDANCE; + const [{ metadata: member }, latest] = await Promise.all([ + kv.getWithMetadata(`member:${uid}`), + kv.get(`latest:${uid}`, 'json'), + ]); + if (!member) { + const { metadata: seen } = await kv.getWithMetadata(`unknown:${uid}`); + const next: UnknownMeta = { + firstSeen: seen?.firstSeen ?? at, + lastSeen: Math.max(seen?.lastSeen ?? at, at), + count: (seen?.count ?? 0) + 1, + }; + // 등록 전 카드 UID는 개인정보이므로 일정 기간 뒤 자동으로 지웁니다. + await kv.put(`unknown:${uid}`, '', { metadata: next, expirationTtl: UNKNOWN_TTL_SECONDS }); + return json({ status: 'unknown' }, 202); + } + + const action = decideTap(latest ? { memberId: uid, ...latest } : null, at); + if (action.kind === 'ignore') return json({ status: 'ignored', name: member.name }); + + const open = (checkIn: number) => + kv.put(sessionKey(uid, checkIn), '', { metadata: { checkOut: null, autoClosed: false } satisfies SessionMeta }); + const close = (checkIn: number, checkOut: number, autoClosed: boolean) => + kv.put(sessionKey(uid, checkIn), '', { metadata: { checkOut, autoClosed } satisfies SessionMeta }); + // 세션을 먼저 쓰고 포인터를 마지막에 갱신합니다. 중간에 실패하면 포인터가 옛 값이라 다음 태그가 다시 판단합니다. + const point = (value: LatestSession) => kv.put(`latest:${uid}`, JSON.stringify(value)); + + switch (action.kind) { + case 'check-in': + await open(at); + await point({ checkIn: at, checkOut: null, autoClosed: false }); + return json({ status: 'check-in', name: member.name, at }); + case 'check-out': + await close(latest!.checkIn, at, false); + await point({ checkIn: latest!.checkIn, checkOut: at, autoClosed: false }); + return json({ status: 'check-out', name: member.name, at, durationMs: at - latest!.checkIn }); + case 'expire-and-check-in': + await close(latest!.checkIn, action.closeAt, true); + await open(at); + await point({ checkIn: at, checkOut: null, autoClosed: false }); + return json({ status: 'check-in', name: member.name, at, previousAutoClosed: true }); + } +} + +async function handleState(env: Env): Promise { + const now = Date.now(); + // 이번 주와, 자정을 걸쳐 주 경계 전날에 시작한 세션(최대 길이 6시간)까지 날짜별 접두사로 읽습니다. + const days: string[] = []; + for (let day = kstWeekStart(now) - DAY_MS; day <= now; day += DAY_MS) days.push(kstDateKey(day)); + const kv = env.ATTENDANCE; + const [members, unknown, ...sessionDays] = await Promise.all([ + listAll(kv, 'member:'), + listAll(kv, 'unknown:'), + ...days.map((day) => listAll(kv, `s:${day}:`)), + ]); + + const names = new Map(members.map((member) => [member.name.slice('member:'.length), member.metadata.name])); + const sessions: SessionRow[] = sessionDays.flat().map(({ name, metadata }) => { + const [, , checkIn, uid] = name.split(':'); + return { memberId: uid, checkIn: Number(checkIn), checkOut: metadata.checkOut, autoClosed: metadata.autoClosed }; + }); + const recent = sessions + .filter((session) => names.has(session.memberId)) + .sort((a, b) => b.checkIn - a.checkIn) + .slice(0, RECENT_LIMIT) + .map((session) => ({ + name: names.get(session.memberId), + checkIn: session.checkIn, + checkOut: session.checkOut, + // 퇴근 태그가 없어 시간 합계에서 빠진 세션(자동 종료 또는 최대 길이 초과). + unrecorded: isUnrecorded(session, now), + })); + + return json({ + now, + // 카드 UID는 화면에 필요 없으므로 내보내지 않습니다. + members: summarize( + members.map((member) => ({ id: member.name.slice('member:'.length), name: member.metadata.name })), + sessions, + now, + ).map(({ id: _id, ...summary }) => summary), + unknownCards: unknown + .map(({ name, metadata }) => ({ uid: name.slice('unknown:'.length), ...metadata })) + .sort((a, b) => b.lastSeen - a.lastSeen) + .slice(0, 20), + recent, + }); +} + +async function handleMember(request: Request, url: URL, env: Env): Promise { + if (!sameOriginJson(request, url)) return json({ error: 'invalid_request' }, 400); + const body = await readJson(request); + if (!body || typeof body.uid !== 'string' || typeof body.name !== 'string') + return json({ error: 'invalid_body' }, 400); + + const uid = body.uid.trim().toUpperCase(); + const name = body.name.trim(); + if (!UID_PATTERN.test(uid)) return json({ error: 'invalid_uid' }, 400); + if (name.length < 1 || name.length > 30) return json({ error: 'invalid_name' }, 400); + + // 같은 카드로 다시 등록하면 이름만 고칩니다. + await env.ATTENDANCE.put(`member:${uid}`, '', { metadata: { name } satisfies MemberMeta }); + await env.ATTENDANCE.delete(`unknown:${uid}`); + return json({ status: 'saved' }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/api/taps') { + return request.method === 'POST' + ? handleTap(request, env) + : json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); + } + + const isDashboardRoute = url.pathname === '/' || url.pathname === '/api/state' || url.pathname === '/api/members'; + if (!isDashboardRoute) return json({ error: 'not_found' }, 404); + if (!(await dashboardAuthorized(request, env))) return new Response('Unauthorized', UNAUTHORIZED); + + if (url.pathname === '/' && request.method === 'GET') { + return new Response(DASHBOARD_HTML, { + headers: { + ...SECURITY_HEADERS, + 'content-type': 'text/html; charset=utf-8', + 'content-security-policy': + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + }, + }); + } + if (url.pathname === '/api/state' && request.method === 'GET') return handleState(env); + if (url.pathname === '/api/members' && request.method === 'POST') return handleMember(request, url, env); + return json({ error: 'method_not_allowed' }, 405); + }, +} satisfies ExportedHandler; diff --git a/apps/attendance/src/logic.ts b/apps/attendance/src/logic.ts new file mode 100644 index 00000000..d4240c79 --- /dev/null +++ b/apps/attendance/src/logic.ts @@ -0,0 +1,137 @@ +export const HOUR_MS = 3_600_000; +export const DAY_MS = 24 * HOUR_MS; +/** 한국 표준시는 DST가 없으므로 고정 오프셋으로 하루·주 경계를 계산합니다. */ +export const KST_OFFSET_MS = 9 * HOUR_MS; +/** 퇴실 태그를 잊은 세션이 무한정 늘어나지 않도록 하는 세션 최대 길이입니다. */ +export const MAX_SESSION_MS = 6 * HOUR_MS; +/** 같은 회원의 직전 이벤트 직후 재태그(카드 중복 인식)를 무시하는 시간입니다. */ +export const DEBOUNCE_MS = 10_000; + +export interface SessionRow { + /** 회원 식별자는 카드 UID입니다. */ + memberId: string; + checkIn: number; + checkOut: number | null; + autoClosed: boolean; +} + +export type TapAction = + | { kind: 'ignore' } + | { kind: 'check-in' } + | { kind: 'check-out' } + /** 미퇴실로 남은 세션을 `closeAt`에 자동 종료하고 새 입실을 시작합니다. */ + | { kind: 'expire-and-check-in'; closeAt: number }; + +/** + * 회원의 가장 최근 세션과 태그 시각으로 입실/퇴실을 정합니다. + * 태그는 토글이며, 오프라인 재전송으로 직전 이벤트보다 이르거나 디바운스 안에 든 태그는 버립니다. + */ +export function decideTap(latest: SessionRow | null, at: number): TapAction { + if (!latest) return { kind: 'check-in' }; + const lastEventAt = latest.checkOut ?? latest.checkIn; + if (at - lastEventAt < DEBOUNCE_MS) return { kind: 'ignore' }; + if (latest.checkOut !== null) return { kind: 'check-in' }; + if (at - latest.checkIn >= MAX_SESSION_MS) { + return { kind: 'expire-and-check-in', closeAt: latest.checkIn + MAX_SESSION_MS }; + } + return { kind: 'check-out' }; +} + +/** `ts`가 속한 KST 날짜의 00:00(UTC epoch ms)입니다. */ +export function kstDayStart(ts: number): number { + return Math.floor((ts + KST_OFFSET_MS) / DAY_MS) * DAY_MS - KST_OFFSET_MS; +} + +/** `ts`가 속한 KST 주(월요일 시작)의 시작입니다. */ +export function kstWeekStart(ts: number): number { + const dayStart = kstDayStart(ts); + // 1970-01-01(목)을 기준으로 월요일=0이 되도록 3일을 더합니다. + const daysSinceMonday = (Math.floor((dayStart + KST_OFFSET_MS) / DAY_MS) + 3) % 7; + return dayStart - daysSinceMonday * DAY_MS; +} + +export function isPresent(session: SessionRow, now: number): boolean { + return session.checkOut === null && now - session.checkIn < MAX_SESSION_MS; +} + +/** + * 퇴근 태그 없이 자동 종료됐거나 최대 길이를 넘겨 열려 있는 세션입니다. + * 실제 체류 시간을 알 수 없으므로 시간 합계에 넣지 않습니다. + */ +export function isUnrecorded(session: SessionRow, now: number): boolean { + return session.autoClosed || (session.checkOut === null && now - session.checkIn >= MAX_SESSION_MS); +} + +function overlapMs(session: SessionRow, from: number, to: number, now: number): number { + if (isUnrecorded(session, now)) return 0; + const start = Math.max(session.checkIn, from); + const end = Math.min(session.checkOut ?? now, to); + return Math.max(0, end - start); +} + +export interface MemberSummary { + id: string; + name: string; + present: boolean; + /** 현재 입실 중일 때 입실 시각. */ + since: number | null; + todayMs: number; + weekMs: number; + /** 이번 주 퇴근 태그가 없어 합계에서 뺀 세션 수. */ + unrecordedWeek: number; + /** 오늘 처음 입실한 시각. 오늘 기록이 없으면 null. */ + firstInToday: number | null; +} + +export function summarize( + members: { id: string; name: string }[], + sessions: SessionRow[], + now: number, +): MemberSummary[] { + const dayStart = kstDayStart(now); + const weekStart = kstWeekStart(now); + const byMember = new Map(); + for (const session of sessions) { + const list = byMember.get(session.memberId); + if (list) list.push(session); + else byMember.set(session.memberId, [session]); + } + + const result = members.map((member): MemberSummary => { + let todayMs = 0; + let weekMs = 0; + let unrecordedWeek = 0; + let since: number | null = null; + let firstInToday: number | null = null; + for (const session of byMember.get(member.id) ?? []) { + todayMs += overlapMs(session, dayStart, dayStart + DAY_MS, now); + weekMs += overlapMs(session, weekStart, weekStart + 7 * DAY_MS, now); + if (session.checkIn >= weekStart && isUnrecorded(session, now)) unrecordedWeek++; + if (isPresent(session, now)) since = session.checkIn; + if (session.checkIn >= dayStart && (firstInToday === null || session.checkIn < firstInToday)) { + firstInToday = session.checkIn; + } + } + return { + id: member.id, + name: member.name, + present: since !== null, + since, + todayMs, + weekMs, + unrecordedWeek, + firstInToday, + }; + }); + + return result.sort((a, b) => { + if (a.present !== b.present) return a.present ? -1 : 1; + if (a.present && b.present) return (a.since ?? 0) - (b.since ?? 0); + return b.weekMs - a.weekMs || a.name.localeCompare(b.name, 'ko'); + }); +} + +/** KV 키 접두사에 쓰는 KST 날짜(YYYYMMDD)입니다. */ +export function kstDateKey(ts: number): string { + return new Date(ts + KST_OFFSET_MS).toISOString().slice(0, 10).replaceAll('-', ''); +} diff --git a/apps/attendance/test/logic.test.mjs b/apps/attendance/test/logic.test.mjs new file mode 100644 index 00000000..4567b140 --- /dev/null +++ b/apps/attendance/test/logic.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DAY_MS, + DEBOUNCE_MS, + HOUR_MS, + MAX_SESSION_MS, + decideTap, + kstDayStart, + kstWeekStart, + summarize, +} from '../src/logic.ts'; + +// 2026-11-10(화) 00:00 KST +const TUE = Date.UTC(2026, 10, 9, 15, 0, 0); +const MON = TUE - DAY_MS; + +const session = (overrides) => ({ memberId: '가', checkIn: TUE, checkOut: null, autoClosed: false, ...overrides }); + +test('KST 날짜·주 경계는 UTC 자정이 아니라 한국 시간 기준이다', () => { + assert.equal(kstDayStart(TUE + 5 * HOUR_MS), TUE); + assert.equal(kstDayStart(TUE - 1), TUE - DAY_MS); + assert.equal(kstWeekStart(TUE + 5 * HOUR_MS), MON); + assert.equal(kstWeekStart(MON), MON); + // 일요일 23:59는 직전 월요일이 속한 주 + assert.equal(kstWeekStart(MON - 1), MON - 7 * DAY_MS); +}); + +test('태그는 입실과 퇴실을 번갈아 만든다', () => { + assert.deepEqual(decideTap(null, TUE), { kind: 'check-in' }); + assert.deepEqual(decideTap(session({}), TUE + HOUR_MS), { kind: 'check-out' }); + assert.deepEqual(decideTap(session({ checkOut: TUE + HOUR_MS }), TUE + 2 * HOUR_MS), { kind: 'check-in' }); +}); + +test('디바운스 안의 태그와 직전 이벤트보다 이른 재전송은 무시한다', () => { + assert.deepEqual(decideTap(session({}), TUE + DEBOUNCE_MS - 1), { kind: 'ignore' }); + assert.deepEqual(decideTap(session({}), TUE - HOUR_MS), { kind: 'ignore' }); + assert.deepEqual(decideTap(session({}), TUE + DEBOUNCE_MS), { kind: 'check-out' }); +}); + +test('최대 길이를 넘긴 미퇴실 세션은 최대 길이 시점에 자동 종료하고 새로 입실한다', () => { + assert.deepEqual(decideTap(session({}), TUE + MAX_SESSION_MS), { + kind: 'expire-and-check-in', + closeAt: TUE + MAX_SESSION_MS, + }); + assert.deepEqual(decideTap(session({}), TUE + MAX_SESSION_MS - 1), { kind: 'check-out' }); +}); + +test('체류 시간은 오늘/이번 주 경계로 잘리고 입실 중이면 현재까지 더한다', () => { + const now = TUE + 3 * HOUR_MS; + const members = [ + { id: '가', name: '가' }, + { id: '나', name: '나' }, + ]; + const sessions = [ + // 월요일 22:00 → 화요일 01:00: 오늘 1시간, 이번 주 3시간 + session({ memberId: '가', checkIn: TUE - 2 * HOUR_MS, checkOut: TUE + HOUR_MS }), + // 화요일 02:00부터 입실 중: 현재까지 1시간 + session({ memberId: '가', checkIn: TUE + 2 * HOUR_MS }), + // 지난주 세션은 합산하지 않는다 + session({ memberId: '나', checkIn: MON - 8 * DAY_MS, checkOut: MON - 8 * DAY_MS + HOUR_MS }), + ]; + const [first, second] = summarize(members, sessions, now); + assert.equal(first.name, '가'); + assert.equal(first.present, true); + assert.equal(first.since, TUE + 2 * HOUR_MS); + assert.equal(first.todayMs, 2 * HOUR_MS); + assert.equal(first.weekMs, 4 * HOUR_MS); + assert.equal(first.firstInToday, TUE + 2 * HOUR_MS); + assert.equal(second.present, false); + assert.equal(second.weekMs, 0); +}); + +test('퇴근 태그가 없는 세션은 입실 중으로 보지 않고 시간 합계에서 빼며 건수만 센다', () => { + const now = TUE + 20 * HOUR_MS; + const sessions = [ + // 최대 길이를 넘겨 열려 있는 세션 + session({ checkIn: TUE }), + // 다음 태그로 자동 종료된 세션 + session({ checkIn: TUE + 8 * HOUR_MS, checkOut: TUE + 8 * HOUR_MS + MAX_SESSION_MS, autoClosed: true }), + // 정상 세션 1시간 + session({ checkIn: TUE + 17 * HOUR_MS, checkOut: TUE + 18 * HOUR_MS }), + ]; + const [member] = summarize([{ id: '가', name: '가' }], sessions, now); + assert.equal(member.present, false); + assert.equal(member.todayMs, HOUR_MS); + assert.equal(member.weekMs, HOUR_MS); + assert.equal(member.unrecordedWeek, 2); +}); diff --git a/apps/attendance/tsconfig.json b/apps/attendance/tsconfig.json new file mode 100644 index 00000000..817dd5d8 --- /dev/null +++ b/apps/attendance/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2024"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc new file mode 100644 index 00000000..ad17b78d --- /dev/null +++ b/apps/attendance/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "bcsd-attendance", + "main": "src/index.ts", + "compatibility_date": "2026-10-29", + "workers_dev": true, + "preview_urls": false, + "observability": { "enabled": true }, + // id는 첫 배포 때 wrangler가 네임스페이스를 만들고 기록합니다. + "kv_namespaces": [{ "binding": "ATTENDANCE" }], +} diff --git a/package.json b/package.json index c103318c..9849021d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "dev:web": "pnpm --filter @koin/web dev", "dev:api": "pnpm --filter @koin/api dev", "dev:b-bot": "pnpm --filter @koin/b-bot dev", + "dev:attendance": "pnpm --filter @koin/attendance dev", "deploy:b-bot": "pnpm --filter @koin/b-bot run deploy", + "deploy:attendance": "pnpm --filter @koin/attendance run deploy", "deploy:api": "pnpm --filter @koin/api run deploy", "deploy:api:preview": "pnpm --filter @koin/api run deploy:preview", "deploy:error-automation": "pnpm --filter @koin/error-automation run deploy", From 77d64b6efc59dc192b7c16415db28de11fff8481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A3=BC?= <145267904+ParkSungju01@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:41:38 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(web):=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=86=B1=20=EC=B0=BD=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=8F=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=BD=9C=EB=B0=B4=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: bcsd-united-error-publisher[bot] <349694229+bcsd-united-error-publisher[bot]@users.noreply.github.com> --- apps/web/src/routes/callvan.index.tsx | 14 +++++++------- apps/web/src/utils/hooks/layout/useMediaQuery.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/callvan.index.tsx b/apps/web/src/routes/callvan.index.tsx index 88ddc209..76fb4b50 100644 --- a/apps/web/src/routes/callvan.index.tsx +++ b/apps/web/src/routes/callvan.index.tsx @@ -9,7 +9,7 @@ import CallvanList from 'components/Callvan/components/CallvanList'; import CallvanPageLayout from 'components/Callvan/components/CallvanPageLayout'; import { type CallvanParams, parseCallvanQuery } from 'components/Callvan/utils/callvanQuery'; import ROUTES from 'static/routes'; -import useMediaQuery from 'utils/hooks/layout/useMediaQuery'; +import useMediaQuery, { MOBILE_QUERY } from 'utils/hooks/layout/useMediaQuery'; import useIsLoggedIn from 'utils/hooks/state/useIsLoggedIn'; import useMount from 'utils/hooks/state/useMount'; import useInfiniteScroll from 'utils/hooks/ui/useInfiniteScroll'; @@ -57,14 +57,13 @@ function useCallvanParams(initialParams: CallvanParams): CallvanParams { function CallvanPage({ initialParams }: Awaited>) { const routeNavigate = useNavigate(); const isMobile = useMediaQuery(); - const mounted = useMount(); - // 서버가 이미 걸러내지만, 창 크기를 줄여 모바일 폭이 된 뒤 넓히는 경우를 위해 남긴다. + // 콜밴은 모바일 폭에서만 연다. 기기(UA)가 아니라 화면 폭으로 판단해 창을 좁힌 데스크톱도 허용한다. useEffect(() => { - if (mounted && !isMobile) { + if (!isMobile && !window.matchMedia(MOBILE_QUERY).matches) { routeNavigate({ href: ROUTES.Main(), replace: true }); } - }, [mounted, isMobile, routeNavigate]); + }, [isMobile, routeNavigate]); const params = useCallvanParams(initialParams); @@ -114,8 +113,9 @@ function CallvanContent({ params }: CallvanContentProps) { } const loadPage = definePageLoad(async ({ search: query, queryClient }, _cacheControl, serverRequest) => { - if (serverRequest.device !== 'mobile') { - throw redirect({ href: ROUTES.Main(), statusCode: 302 }); + // 화면 이동은 브라우저에서 실행되므로 실제 폭으로 바로 거른다. SSR은 폭을 모르니 화면 컴포넌트가 거른다. + if (typeof window !== 'undefined' && !window.matchMedia(MOBILE_QUERY).matches) { + throw redirect({ href: ROUTES.Main() }); } const params = parseCallvanQuery(query, DEFAULT_PARAMS); diff --git a/apps/web/src/utils/hooks/layout/useMediaQuery.ts b/apps/web/src/utils/hooks/layout/useMediaQuery.ts index 76c9166b..97f6f507 100644 --- a/apps/web/src/utils/hooks/layout/useMediaQuery.ts +++ b/apps/web/src/utils/hooks/layout/useMediaQuery.ts @@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react'; import { useServerRequest } from 'utils/ssr/useServerRequest'; -const MOBILE_QUERY = '(max-width: 576px)'; +export const MOBILE_QUERY = '(max-width: 576px)'; function subscribeToMediaQuery(query: string) { return (onStoreChange: () => void) => { From 72c16811d321551a0cbb37a14f7d54a08d0f3b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunsup=20Sim=28=EC=8B=AC=EC=9C=A4=EC=84=AD=29?= Date: Fri, 13 Nov 2026 23:10:30 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=B6=9C=EA=B7=BC=EB=B6=80=20Mater?= =?UTF-8?q?ial=203=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=99=80=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=A0=81=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 출근부 Material 3 무스크롤 대시보드 구성 * fix: 출근부 조회 기한과 입력 포커스를 보장한다 상태 조회와 등록 요청을 응답 본문까지 10초로 제한한다. 조회 실패 시 이전 데이터와 멈춘 체류 시계를 유지한다. 자동 갱신 버튼과 미등록 카드 페이지의 입력 포커스를 보존한다. 운영 KV를 명시해 재생성을 막고 비공개 설정과 비밀값을 유지한다. 검증: attendance 타입 검사와 동작 테스트 6개, Wrangler dry-run 통과. 실제 격리 Worker/KV와 브라우저에서 시간 초과, 복구, 포커스를 확인했다. 1280x720과 1920x1080에서 긴 목록 무스크롤을 확인했다. 운영 데이터, 접근 정책, 토큰 값은 변경하지 않았다. --------- Co-authored-by: bcsd-united-error-publisher[bot] <349694229+bcsd-united-error-publisher[bot]@users.noreply.github.com> --- apps/attendance/README.md | 45 +- apps/attendance/src/dashboard.ts | 744 ++++++++++++++++++++++++++----- apps/attendance/wrangler.jsonc | 6 +- 3 files changed, 670 insertions(+), 125 deletions(-) diff --git a/apps/attendance/README.md b/apps/attendance/README.md index e2e1af08..72417283 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -36,17 +36,27 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 | `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | | `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +## 대시보드 + +- 서버가 제공하는 단일 HTML 화면에 [Mobbin의 공개 업무용 대시보드 패턴](https://mobbin.com/explore/web/screens/analytics-dashboard)을 참고한 Material Design 3의 색상·서피스·타이포그래피를 적용합니다. 사이드바 없이 구성하며 외부 폰트나 UI 라이브러리를 불러오지 않습니다. +- 현재 인원과 입실 중인 회원의 체류 시간을 크게 표시하고, 전체 회원의 오늘·이번 주 체류 시간 합계와 퇴근 미기록 건수를 보여 줍니다. 회원별 표와 이번 주 최근 세션 최대 15건을 함께 제공합니다. +- 16:9 데스크톱 화면에서는 본문 스크롤 없이 주요 패널을 한 화면에 배치하고, 긴 목록은 각 패널의 이전·다음 버튼으로 넘깁니다. 화면 크기에 맞춰 표시 개수가 바뀌고 새로고침 후에도 선택한 페이지를 유지합니다. +- 테마 버튼은 자동(시스템 설정) → 라이트 → 다크 순서로 전환하며 선택을 브라우저에 저장합니다. 작은 화면에서는 카드가 재배치되고 표는 가로로 스크롤할 수 있습니다. +- 데이터는 10초마다 갱신하고 입실 중인 회원의 체류 시계는 매초 갱신합니다. 상태 조회·등록 요청은 응답 본문 수신까지 10초로 제한하며, 새로고침 버튼으로 즉시 갱신할 수 있습니다. 연결 실패 시 마지막 데이터임을 표시하고 체류 시계를 마지막 동기화 시각에 멈춥니다. 처음부터 불러오지 못한 경우 수치를 임의로 표시하지 않습니다. +- 미등록 카드는 화면에서 이름을 입력해 등록합니다. 주기적 갱신에도 입력과 포커스를 유지하며 등록 중·완료·오류 상태를 카드별로 표시합니다. 등록 전 태그는 입실 기록이 아니므로 등록 후 카드를 다시 태그해야 합니다. +- 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. +- 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. + ## 배포 ```sh -pnpm --filter @koin/attendance exec wrangler secret put DEVICE_TOKEN -pnpm --filter @koin/attendance exec wrangler secret put DASHBOARD_PASSWORD +# 기존 DEVICE_TOKEN·DASHBOARD_PASSWORD는 유지합니다. 비밀값 설정·회전은 별도 운영 절차입니다. pnpm deploy:attendance ``` -KV 네임스페이스는 첫 배포 때 wrangler가 만들고 `id`를 기록합니다. 기록된 `wrangler.jsonc`를 커밋합니다. 토큰은 충분히 긴 무작위 값을 사용합니다(`openssl rand -hex 32`). +`wrangler.jsonc`의 `ATTENDANCE`는 기존 운영 KV 네임스페이스를 고정합니다. 새 네임스페이스를 만들거나 id를 바꾸면 기존 데이터와 분리되므로 배포 중 치환하지 않습니다. 새 토큰이 필요할 때만 충분히 긴 무작위 값으로 별도 설정합니다(`openssl rand -hex 32`). -벽걸이 화면은 `https://:@/` 로 열면 인증 창 없이 표시됩니다. +벽걸이 화면은 대시보드 경로(`/`)를 열고 Basic 인증 창에서 `DASHBOARD_PASSWORD`로 인증합니다. 비밀번호를 URL에 넣지 않습니다. ## 로컬 실행 @@ -98,12 +108,27 @@ Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. ### 설치 ```sh -sudo raspi-config nonint do_spi 0 # rc522: SPI 활성화(/dev/spidev0.0) -sudo apt install python3-spidev python3-rpi.gpio # rc522. hid는 python3-evdev -sudo install -D pi/reader.py /opt/attendance/reader.py -sudo install -m 600 /dev/null /etc/attendance-reader.env # ATTENDANCE_URL=…, DEVICE_TOKEN=…, READER=rc522 -sudo install pi/attendance-reader.service /etc/systemd/system/ -sudo systemctl enable --now attendance-reader +sudo raspi-config nonint do_spi 0 # rc522: 부팅 시 SPI 활성화 +sudo dtparam spi=on # rc522: 재부팅 없이 현재 부팅에도 적용 +sudo apt install python3-spidev python3-rpi-lgpio # Raspberry Pi OS Trixie. hid는 python3-evdev +sudo install -D -o root -g root -m 755 pi/reader.py /opt/attendance/reader.py +sudo install -o root -g root -m 644 pi/attendance-reader.service /etc/systemd/system/ +sudo systemctl daemon-reload +``` + +RC522는 `/dev/spidev0.0`과 위 배선을 사용합니다. SPI 장치 파일이 있다는 것만으로 모듈 연결이 확인되지는 않습니다. 시작 시 `RC522 응답 없음`이면 3.3V 전원·GND·CE0·SCLK·MOSI·MISO·RST 배선을 확인합니다. USB HID는 `/dev/input/by-id/*-event-kbd`에 실제 리더가 있어야 합니다. + +실제 Worker 주소와 해당 Worker의 `DEVICE_TOKEN`을 받은 뒤에만 런타임 설정을 작성하고 서비스를 시작합니다. 진단 중이거나 모듈·카드 인식이 확인되지 않았다면 전송 루프를 켜지 않습니다. 네트워크 접근 정책이 있는 배포는 Pi에서도 허용된 경로로 Worker에 접근할 수 있어야 합니다. + +```sh +sudo touch /etc/attendance-reader.env +sudo chown root:root /etc/attendance-reader.env +sudo chmod 600 /etc/attendance-reader.env +sudoedit /etc/attendance-reader.env +# ATTENDANCE_URL=<실제 Worker 기본 주소> +# DEVICE_TOKEN=<해당 Worker의 실제 토큰> +# READER=rc522 (USB HID는 hid; 필요하면 READER_DEVICE도 지정) +sudo systemctl enable --now attendance-reader.service ``` - 오프라인이면 태그를 `/var/lib/attendance/queue.jsonl`에 보관하고 30초마다 순서대로 재전송합니다. 태그 시각(`at`)은 파이 시계 기준이므로 NTP 동기화가 필요합니다. diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 11f6b90f..519ce51c 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -8,74 +8,435 @@ export const DASHBOARD_HTML = /* html */ ` + BCSD 출근부 -
-
-

BCSD 출근부

-
+
+
+
+
+
+

CLUBROOM / ATTENDANCE

출입 현황

+
+
+
한국 표준시 · KST
+ + +
- -
-

지금 있는 사람

-
- -
-
-

이번 주 체류 시간 (월요일 시작, KST)

- - - -
이름상태오늘 첫 입실오늘이번 주퇴근 미기록
-
- -
-

최근 기록

- - - -
이름입실퇴실체류
-
-
+
+ +
+
+

지금 동아리방에는

등록 회원 확인 중

+
—명
+

함께 몰입하는 시간, 이곳에서.

+
+
+

지금 있는 사람

입실 현황
+

동아리방 현황을 불러오고 있습니다.

+
+ +
+
+
+

오늘 함께한 시간

—

전체 회원 체류 시간 합계

+

이번 주 함께한 시간

—

월요일부터 지금까지 · KST

+

이번 주 퇴근 미기록

—

미기록 세션은 시간 합계에서 제외

+
+
+

이번 주의 발자취

회원별 입실 현황과 오늘 · 이번 주 체류 시간

확인 중
+

회원별 기록을 불러오고 있습니다.

+ + +

퇴실 태그 없이 6시간이 지난 세션은 ‘퇴근 미기록’으로 표시하며 체류 시간에서 제외합니다.

+
+
+
+

최근 기록

이번 주 기록 중 최근 입실 순 · 최대 15건

확인 중
+

최근 입실 · 퇴실 기록을 불러오고 있습니다.

+ + +
+
+

미등록 카드

카드의 주인을 연결해 주세요.

확인 중
+

새로 태그한 카드를 확인하고 있습니다.

+
+ +

이름은 최대 30자입니다. 등록 후 카드를 다시 태그하면 입실이 기록됩니다. 미등록 카드는 7일 뒤 자동 삭제됩니다.

+
+
+
첫 데이터를 불러오는 중

아직 동기화되지 않음 · 10초마다 갱신

+

+
+

BCSD LAB · 작은 태그 하나로 이어지는 우리의 하루

모든 시각은 KST · 저장소 반영에 최대 약 60초 소요될 수 있습니다.

+
+
+ diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index ad17b78d..cbc04d7b 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -3,9 +3,9 @@ "name": "bcsd-attendance", "main": "src/index.ts", "compatibility_date": "2026-10-29", - "workers_dev": true, + "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, - // id는 첫 배포 때 wrangler가 네임스페이스를 만들고 기록합니다. - "kv_namespaces": [{ "binding": "ATTENDANCE" }], + // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. + "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], } From a2dba4e13f9722c7b1ff490f375abbaf16813bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunsup=20Sim=28=EC=8B=AC=EC=9C=A4=EC=84=AD=29?= Date: Sat, 14 Nov 2026 09:35:20 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20Rocky=20?= =?UTF-8?q?=ED=84=B0=EB=84=90=EC=9D=84=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=88=ED=84=B0=20=EC=97=B0=EB=8F=99=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 오류 등록 상태를 읽기 전용 대시보드로 제공한다 전용 모니터 Bearer와 고정 Worker 바인딩으로 수신 요약만 조회한다. 고유 오류 등록 건수와 처리 상태만 반환하고 모델·잡 실행을 하지 않는다. VPC·SSH·CPU/RAM 감시를 추가하지 않고 koin-api 기록을 제외한다. 오류·오래된 응답은 알 수 없음으로 표시하고 비밀값을 브라우저에 주지 않는다. 검증: 관련 패키지 3개 타입 검사, 오류 자동화 122개와 출근부 14개 테스트 통과. oxlint와 두 Worker dry-run을 통과했다. 실제 로컬 두 Worker와 SQLite에서 정상 빈 조회·제공자 중단·복구를 확인했다. 1280x720·1920x1080 무스크롤 및 모바일 가로 넘침 없음을 확인했다. 운영 활성화는 main 승격과 별도 모니터 secret 설정 후에 수행한다. * feat(monitor): 기존 Rocky 터널로 읽기 전용 헬스 연결 수신 집계는 최근 목록과 독립적으로 전체 24시간을 계산합니다. Rocky 전용 Bearer와 고정 VPC healthz로 CPU·RAM·서비스 상태만 조회합니다. 모델 호출 없이 수신 요약과 호스트 장애를 분리합니다. --- apps/attendance/README.md | 25 +- apps/attendance/package.json | 5 +- apps/attendance/src/automation.ts | 98 +++++++ apps/attendance/src/dashboard.ts | 217 ++++++++++++++- apps/attendance/src/index.ts | 17 +- apps/attendance/test/automation.test.mjs | 288 ++++++++++++++++++++ apps/attendance/tsconfig.json | 2 +- apps/attendance/worker-configuration.d.ts | 37 +++ apps/attendance/wrangler.jsonc | 1 + apps/error-automation/README.md | 5 + apps/error-automation/package.json | 5 +- apps/error-automation/src/index.ts | 90 +++++- apps/error-automation/src/queue.ts | 98 ++++++- apps/error-automation/src/types.ts | 4 + apps/error-automation/test/monitor.test.mjs | 267 ++++++++++++++++++ apps/error-automation/test/queue.test.mjs | 222 +++++++++++++++ packages/telemetry/package.json | 1 + packages/telemetry/src/monitor.ts | 272 ++++++++++++++++++ pnpm-lock.yaml | 9 + scripts/autofix/README.md | 36 ++- scripts/autofix/koin-omp-gateway.service | 2 + scripts/autofix/local-omp-gateway.mjs | 267 ++++++++++++++++-- scripts/autofix/local-omp-gateway.test.mjs | 276 +++++++++++++++++++ 23 files changed, 2202 insertions(+), 42 deletions(-) create mode 100644 apps/attendance/src/automation.ts create mode 100644 apps/attendance/test/automation.test.mjs create mode 100644 apps/attendance/worker-configuration.d.ts create mode 100644 apps/error-automation/test/monitor.test.mjs create mode 100644 packages/telemetry/src/monitor.ts diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 72417283..0e6d9c25 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -29,12 +29,13 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 ## API -| 경로 | 인증 | 용도 | -| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | -| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | -| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | -| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +| 경로 | 인증 | 용도 | +| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | +| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | +| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | +| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +| `GET /api/automation` | Basic | 독립적인 오류 수신 요약과 Rocky 읽기 전용 헬스. RFID Bearer로 접근 불가 | ## 대시보드 @@ -47,6 +48,18 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 - 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. - 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. +## 오류 수신·Rocky 읽기 전용 모니터 + +- 서버의 `ERROR_AUTOMATION` Worker service binding이 고정된 `bcsd-error-automation`의 `GET /healthz`와 `GET /healthz/rocky`를 병렬로 읽습니다. 브라우저·Pi에는 관리/모델/진단/호스트 모니터 토큰을 주지 않습니다. +- 두 Worker에 별도의 동일한 `ERROR_MONITOR_TOKEN`을 secret으로 설정해야 합니다(32자 이상 무작위 값). `DEVICE_TOKEN`, `DASHBOARD_PASSWORD`, 실행기·진단·모델 토큰을 재사용하지 않습니다. secret 설정은 배포 작업이므로 정상 운영 승인 절차를 따릅니다. +- 수신 요약은 Sentry/Cloudflare의 최근 24시간 **고유 오류 등록 건수**, 최근 신규 등록 시각과 최대 10개의 처리 상태입니다. 건수와 최근 시각은 목록 제한과 독립적으로 전체 기간에서 계산합니다. 중복 웹훅 전달 횟수나 마지막 재전송 시각이 아니며 GitHub PR 리뷰와 미사용 `koin-api` 기록은 제외합니다. +- Rocky는 기존 `OMP_GATEWAY` VPC와 `koin-omp-gateway-rocky` 터널을 통해 게이트웨이의 고정 `GET /healthz`만 조회합니다. `OMP_GATEWAY_MONITOR_TOKEN`은 error-automation과 호스트만 공유하는 별도 secret이며 모델·실행기·수신 요약 토큰과 같으면 거부합니다. 호스트 설정은 [게이트웨이 운영 계약](../../scripts/autofix/README.md#rocky-읽기-전용-헬스-활성화)을 따릅니다. +- CPU는 Linux `/proc/stat` 두 표본의 busy 비율, RAM 사용량은 `MemTotal − MemAvailable`입니다. 실행기·브로커는 고정 systemd 서비스의 설치/활성/프로세스 상태를 구분합니다. 서비스 활성이나 프로세스 실행은 브로커 HTTP 응답·모델 인증·작업 완료의 증거가 아니며 모델 상태는 미확인으로 표시합니다. +- 오류 전문·프롬프트·소스·로그·원본 provider ID·자격 증명을 반환하지 않습니다. 조회가 작업 claim, 모델 생성, GitHub 작업을 시작하지 않습니다. +- 출근부 조회와 별개로 60초마다 갱신합니다. 조회별 5초 본문 수신 기한·16 KiB JSON 한도·리다이렉트 거절을 적용하고 120초보다 오래된 스냅샷은 unknown으로 처리합니다. +- `/api/automation`의 최상위 `state`/`data`는 수신 요약이고 `rocky.state`/`rocky.data`는 호스트 관측입니다. 한쪽 실패가 다른 쪽의 관측을 지우지 않습니다. 미설정·연결 실패·응답 불일치는 해당 결과의 `state: unknown`, `data: null`이며 측정 실패 CPU/RAM도 null입니다. 실제 조회한 빈 저장소만 0건으로 표시하고 조용한 수신량을 장애로 판단하지 않습니다. +- 연결 실패·오프라인·숨긴 화면·확인 기한 초과 시 마지막 관측은 과거 기록임을 표시합니다. 조직 Gateway 보호와 기존 비공개 노출 경계는 유지하며 새 터널·공개 SSH·WARP/OpenBao 권한은 추가하지 않습니다. + ## 배포 ```sh diff --git a/apps/attendance/package.json b/apps/attendance/package.json index 2707b15f..03da9822 100644 --- a/apps/attendance/package.json +++ b/apps/attendance/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "wrangler dev", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/logic.test.mjs", + "test": "node --experimental-strip-types --test test/*.test.mjs", "deploy": "wrangler deploy" + }, + "dependencies": { + "@koin/telemetry": "workspace:*" } } diff --git a/apps/attendance/src/automation.ts b/apps/attendance/src/automation.ts new file mode 100644 index 00000000..701fc508 --- /dev/null +++ b/apps/attendance/src/automation.ts @@ -0,0 +1,98 @@ +import { + parseErrorMonitorSnapshot, + parseRockyMonitorSnapshot, + readMonitorJson, + type ErrorMonitorSnapshot, + type RockyMonitorSnapshot, +} from '@koin/telemetry/monitor'; + +export type AutomationEnv = Partial> & { + ERROR_MONITOR_TOKEN?: string; +}; + +interface MonitorResult { + state: 'ok' | 'unknown'; + checkedAt: number | null; + data: TSnapshot | null; + reason?: 'not_configured' | 'unavailable' | 'stale'; +} + +export type AutomationResult = MonitorResult; +export interface AutomationDashboardResult extends AutomationResult { + rocky: MonitorResult; +} + +const DEADLINE_MS = 5_000; +const FRESH_MS = 120_000; +const HEALTH_URL = 'https://error-automation.internal/healthz'; +const ROCKY_HEALTH_URL = 'https://error-automation.internal/healthz/rocky'; +const UNKNOWN_HEADERS = { 'cache-control': 'no-store' }; + +function unknown(reason: NonNullable): MonitorResult { + return { state: 'unknown', checkedAt: null, data: null, reason }; +} + +/** The deadline covers both the binding fetch and consumption of the complete response. */ +async function getMonitorResult( + env: AutomationEnv, + url: string, + parse: (value: unknown, now: number) => TSnapshot | null, + now: () => number, +): Promise> { + const binding = env.ERROR_AUTOMATION; + const token = env.ERROR_MONITOR_TOKEN; + if (!binding || !token || token.length < 32) return unknown('not_configured'); + + const controller = new AbortController(); + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + controller.abort(); + reject(new Error('monitor deadline exceeded')); + }, DEADLINE_MS); + try { + const task = async (): Promise> => { + const response = await binding.fetch(url, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + redirect: 'manual', + signal: controller.signal, + }); + if (response.status !== 200 || response.redirected) { + void response.body?.cancel().catch(() => {}); + return unknown('unavailable'); + } + const payload = await readMonitorJson(response, controller.signal); + const checkedAt = + payload !== null && typeof payload === 'object' && !Array.isArray(payload) && 'checkedAt' in payload + ? payload.checkedAt + : null; + const currentTime = now(); + const snapshot = parse(payload, currentTime); + if (!snapshot) + return unknown( + typeof checkedAt === 'number' && Number.isFinite(checkedAt) && currentTime - checkedAt > FRESH_MS + ? 'stale' + : 'unavailable', + ); + return { state: 'ok', checkedAt: snapshot.checkedAt, data: snapshot }; + }; + return await Promise.race([task(), deadline]); + } catch { + return unknown('unavailable'); + } finally { + clearTimeout(timer); + } +} + +export function getAutomationResult(env: AutomationEnv, now: () => number = Date.now): Promise { + return getMonitorResult(env, HEALTH_URL, parseErrorMonitorSnapshot, now); +} + +export async function handleAutomation(env: AutomationEnv): Promise { + const [ingress, rocky] = await Promise.all([ + getAutomationResult(env), + getMonitorResult(env, ROCKY_HEALTH_URL, parseRockyMonitorSnapshot, Date.now), + ]); + const result: AutomationDashboardResult = { ...ingress, rocky }; + return Response.json(result, { headers: UNKNOWN_HEADERS }); +} diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 519ce51c..7a23d4d8 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -144,6 +144,22 @@ export const DASHBOARD_HTML = /* html */ ` #sync-status[data-state="error"] { color:var(--error); } .sync-note { margin:0; color:var(--muted); font-size:12px; } footer { display:flex; justify-content:space-between; flex-wrap:wrap; gap:8px; padding:24px 4px 0; color:var(--muted); font-size:11px; } + .monitor-strip { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 16px; border:1px solid var(--line); border-radius:18px; background:var(--surface); box-shadow:var(--shadow); } + .monitor-panels { flex:1; min-width:0; display:grid; gap:6px; } + .monitor-summary { display:flex; align-items:center; flex-wrap:wrap; gap:6px 18px; min-width:0; font-size:12px; } + .monitor-summary strong { font-size:13px; } + .monitor-summary small { color:var(--muted); } + #monitor-state[data-state="ok"], #rocky-state[data-state="ok"] { background:var(--green-container); color:var(--on-green-container); } + #monitor-state[data-state="unknown"], #rocky-state[data-state="unknown"] { background:var(--amber-container); color:var(--amber); } + .monitor-dialog { width:min(650px,calc(100% - 32px)); max-height:min(80vh,700px); overflow:auto; border:1px solid var(--outline); border-radius:24px; padding:22px; background:var(--surface); color:var(--text); box-shadow:0 20px 60px #10131b44; } + .monitor-dialog::backdrop { background:#10131b88; } + .monitor-dialog header { display:flex; justify-content:space-between; align-items:center; gap:12px; } + .monitor-dialog ul { list-style:none; margin:12px 0; padding:0; } + .monitor-dialog li { padding:8px 0; border-bottom:1px solid var(--line); font-size:13px; overflow-wrap:anywhere; } + @media (max-width:700px) { + .monitor-strip { align-items:flex-start; } + .monitor-summary { gap:4px 10px; } + } footer p { margin:0; } @media (min-width:1600px) { .app-shell { max-width:1600px; } @@ -213,7 +229,12 @@ export const DASHBOARD_HTML = /* html */ ` #date { font-size:10px; } .header-actions { gap:7px; } .header-actions button { min-height:36px; padding:8px 12px; font-size:11px; } - main { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr); grid-template-rows:auto minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1fr) 18px; grid-template-areas:"error error" "hero summary" "members unknown" "recent unknown" "sync sync"; gap:10px 12px; } + main { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr); grid-template-rows:auto auto minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1fr) 18px; grid-template-areas:"monitor monitor" "error error" "hero summary" "members unknown" "recent unknown" "sync sync"; gap:8px 12px; } + .monitor-strip { grid-area:monitor; padding:4px 12px; min-height:38px; border-radius:12px; overflow:hidden; } + .monitor-summary { gap:2px 12px; font-size:clamp(9px,1.1vh,14px); line-height:1.3; } + .monitor-summary strong { font-size:inherit; } + .monitor-summary small { font-size:inherit; } + .monitor-strip button { min-height:30px; padding:4px 10px; font-size:10px; } #error { grid-area:error; padding:8px 12px; border-radius:12px; } #error p { font-size:11px; } .hero { grid-area:hero; grid-template-columns:clamp(180px,13vw,340px) minmax(0,1fr); grid-template-rows:minmax(0,1fr); } @@ -293,6 +314,28 @@ export const DASHBOARD_HTML = /* html */ `
+
+
+
+ 오류 수신 · 24시간 + 확인 중 + Sentry 고유 등록 — · 최근 신규 — + Cloudflare 고유 등록 — · 최근 신규 — + 수신 확인 — +
+
+ Rocky + 확인 중 + CPU — + RAM — + 실행기 — + 브로커 — + 호스트 확인 — + 브로커 HTTP·모델 실행 미확인 +
+
+ +
+ +

오류 수신 · Rocky 관측

+

+
    +

    Rocky 읽기 전용 헬스

    +

    아직 조회된 호스트 정보가 없습니다.

    +
    diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts index 359b5ffc..f34eaa94 100644 --- a/apps/attendance/src/index.ts +++ b/apps/attendance/src/index.ts @@ -1,7 +1,8 @@ -import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic'; -import { DASHBOARD_HTML } from './dashboard'; +import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic.ts'; +import { DASHBOARD_HTML } from './dashboard.ts'; +import { handleAutomation } from './automation.ts'; -interface Env { +interface Env extends Cloudflare.Env { /** * 키 구성 (값은 비어 있고 데이터는 모두 메타데이터에 두어 list 한 번으로 읽습니다): * - `member:` 메타 {name} @@ -9,11 +10,12 @@ interface Env { * - `s::<입실 시각 13자리>:` 메타 {checkOut, autoClosed} — 세션 * - `latest:` 값 JSON {checkIn, checkOut, autoClosed} — 회원의 최근 세션 포인터 */ - ATTENDANCE: KVNamespace; /** 라즈베리파이가 태그 이벤트를 보낼 때 쓰는 Bearer 토큰. */ DEVICE_TOKEN: string; /** 대시보드·회원 등록용 Basic 인증 비밀번호(사용자 이름은 무시). */ DASHBOARD_PASSWORD: string; + /** 서버 측에서만 사용하는 오류 자동화 모니터 토큰. */ + ERROR_MONITOR_TOKEN?: string; } interface MemberMeta { @@ -250,7 +252,11 @@ export default { : json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); } - const isDashboardRoute = url.pathname === '/' || url.pathname === '/api/state' || url.pathname === '/api/members'; + const isDashboardRoute = + url.pathname === '/' || + url.pathname === '/api/state' || + url.pathname === '/api/members' || + url.pathname === '/api/automation'; if (!isDashboardRoute) return json({ error: 'not_found' }, 404); if (!(await dashboardAuthorized(request, env))) return new Response('Unauthorized', UNAUTHORIZED); @@ -265,6 +271,7 @@ export default { }); } if (url.pathname === '/api/state' && request.method === 'GET') return handleState(env); + if (url.pathname === '/api/automation' && request.method === 'GET') return handleAutomation(env); if (url.pathname === '/api/members' && request.method === 'POST') return handleMember(request, url, env); return json({ error: 'method_not_allowed' }, 405); }, diff --git a/apps/attendance/test/automation.test.mjs b/apps/attendance/test/automation.test.mjs new file mode 100644 index 00000000..732f2f45 --- /dev/null +++ b/apps/attendance/test/automation.test.mjs @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import { timingSafeEqual } from 'node:crypto'; +import test from 'node:test'; +import worker from '../src/index.ts'; +import { getAutomationResult, handleAutomation } from '../src/automation.ts'; + +// Workers expose crypto.subtle.timingSafeEqual; Node's Web Crypto does not. +if (!globalThis.crypto.subtle.timingSafeEqual) { + Object.defineProperty(globalThis.crypto.subtle, 'timingSafeEqual', { + value: (left, right) => timingSafeEqual(Buffer.from(left), Buffer.from(right)), + configurable: true, + }); +} + +const NOW = Date.UTC(2026, 10, 13, 12); +const TOKEN = 'm'.repeat(32); +const AUTH = 'Basic ' + btoa('dashboard:secret'); +const JOB_ID = 'autofix-' + 'a'.repeat(64); +const snapshot = (overrides = {}) => ({ + version: 1, + scope: 'ingress-only', + checkedAt: NOW, + windowStart: NOW - 24 * 60 * 60 * 1000, + worker: 'responding', + sources: { + sentry: { count: 1, latestAt: NOW - 1000 }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [{ id: JOB_ID, source: 'sentry', app: 'web', registeredAt: NOW - 1000, status: 'queued' }], + ...overrides, +}); +const json = (value) => Response.json(value); +const binding = (fetch) => ({ fetch }); +const env = (fetch) => ({ + ERROR_AUTOMATION: binding(fetch), + ERROR_MONITOR_TOKEN: TOKEN, + DASHBOARD_PASSWORD: 'secret', + DEVICE_TOKEN: 'device-secret', + ATTENDANCE: new Proxy( + {}, + { + get: () => { + throw new Error('monitor must never touch attendance KV'); + }, + }, + ), +}); +const request = (headers = {}, path = '/api/automation') => + new Request('https://attendance.example' + path, { headers }); + +// The route, not a test-only wrapper, must enforce dashboard Basic before any binding call. +test('automation route rejects unauthenticated and RFID Bearer callers before upstream access', async (t) => { + t.mock.method(Date, 'now', () => NOW); + let calls = 0; + const workerEnv = env(async (url) => { + calls++; + assert.ok( + ['https://error-automation.internal/healthz', 'https://error-automation.internal/healthz/rocky'].includes(url), + ); + return json(snapshot()); + }); + for (const headers of [{}, { authorization: 'Bearer device-secret' }, { authorization: 'Basic !!!' }]) { + const response = await worker.fetch(request(headers), workerEnv); + assert.equal(response.status, 401); + assert.match(response.headers.get('www-authenticate'), /Basic/); + } + assert.equal(calls, 0); + const response = await worker.fetch( + request({ authorization: AUTH }, '/api/automation?url=https%3A%2F%2Fattacker.example%2Fhealthz'), + workerEnv, + ); + assert.equal(response.status, 200); + assert.equal(calls, 2); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).state, 'ok'); +}); + +test('fixed service binding, HTTPS path, private token, and no redirect follow', async () => { + let calls = 0; + const result = await getAutomationResult( + env(async (url, options) => { + calls++; + assert.equal(url, 'https://error-automation.internal/healthz'); + assert.equal(options.method, 'GET'); + assert.equal(options.headers.authorization, 'Bearer ' + TOKEN); + assert.equal(options.redirect, 'manual'); + assert.ok(options.signal); + return Response.redirect('https://attacker.example/collect', 302); + }), + () => NOW, + ); + assert.equal(calls, 1); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + assert.ok(!JSON.stringify(result).includes(TOKEN)); +}); + +test('missing or short server-side secret and missing binding never fabricate empty traffic', async () => { + let calls = 0; + const base = env(async () => { + calls++; + return json(snapshot()); + }); + for (const settings of [ + { ERROR_AUTOMATION: undefined }, + { ERROR_MONITOR_TOKEN: undefined }, + { ERROR_MONITOR_TOKEN: 'device-secret' }, + ]) { + const result = await getAutomationResult({ ...base, ...settings }, () => NOW); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'not_configured' }); + } + assert.equal(calls, 0); +}); + +test('quiet observed zero is healthy; stale, malformed, and sensitive extra fields are unknown', async () => { + const empty = snapshot({ + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }); + const good = await getAutomationResult( + env(async () => json(empty)), + () => NOW, + ); + assert.equal(good.state, 'ok'); + assert.equal(good.data.sources.sentry.count, 0); + for (const payload of [ + snapshot({ + checkedAt: NOW - 121_000, + windowStart: NOW - 121_000 - 24 * 60 * 60 * 1000, + sources: empty.sources, + recent: [], + }), + snapshot({ sources: { sentry: { count: -1, latestAt: NOW }, cloudflare: { count: 0, latestAt: null } } }), + snapshot({ recent: [{ ...snapshot().recent[0], diagnostic: 'private stack trace' }] }), + snapshot({ pr_url: 'https://github.example/private' }), + ]) { + const result = await getAutomationResult( + env(async () => json(payload)), + () => NOW, + ); + assert.equal(result.state, 'unknown'); + assert.equal(result.data, null); + assert.equal(result.checkedAt, null); + assert.ok(!JSON.stringify(result).includes('private stack trace')); + } +}); + +test('stale but structurally valid snapshot has explicit unknown/stale state, not zero', async () => { + const checkedAt = NOW - 121_000; + const payload = snapshot({ + checkedAt, + windowStart: checkedAt - 24 * 60 * 60 * 1000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }); + assert.deepEqual( + await getAutomationResult( + env(async () => json(payload)), + () => NOW, + ), + { + state: 'unknown', + checkedAt: null, + data: null, + reason: 'stale', + }, + ); +}); + +test('body size bounds apply even to streaming responses without Content-Length', async () => { + const oversized = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(10_000)); + controller.enqueue(new Uint8Array(7_000)); + controller.close(); + }, + }); + for (const response of [ + () => new Response('x', { headers: { 'content-type': 'application/json', 'content-length': '16385' } }), + () => new Response(oversized, { headers: { 'content-type': 'application/json' } }), + () => new Response(Uint8Array.of(0xff), { headers: { 'content-type': 'application/json' } }), + () => new Response(JSON.stringify(snapshot()), { headers: { 'content-type': 'text/html' } }), + ]) { + const result = await getAutomationResult( + env(async () => response()), + () => NOW, + ); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + } +}); + +test('five-second deadline covers a stalled response body, not merely binding fetch', { timeout: 8_000 }, async () => { + let aborted = false; + const start = performance.now(); + const result = await getAutomationResult( + env(async (_url, options) => { + options.signal.addEventListener('abort', () => { + aborted = true; + }); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(123)); + }, + }), + { + headers: { 'content-type': 'application/json' }, + }, + ); + }), + () => NOW, + ); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + assert.ok(aborted); + assert.ok(performance.now() - start >= 4_900); +}); + +test('API response does not expose monitor token, upstream diagnostic fields, or cacheable data', async () => { + const response = await handleAutomation(env(async () => json(snapshot()))); + const body = await response.text(); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.ok(!body.includes(TOKEN)); + assert.ok(!body.includes('diagnostic')); + assert.ok(!body.includes('sourceId')); + assert.ok(!body.includes('pr_url')); +}); + +function rockySnapshot() { + const unknownUnit = { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }; + return { + version: 1, + scope: 'rocky-read-only', + checkedAt: NOW, + gateway: 'responding', + cpu: { usagePercent: 12.5, sampleStart: NOW - 200, sampleEnd: NOW }, + memory: { totalBytes: 8192, availableBytes: 6144, usedBytes: 2048, checkedAt: NOW }, + executor: { configured: true, state: 'active', process: 'running', checkedAt: NOW }, + broker: unknownUnit, + brokerResponse: 'not_probed', + model: 'not_probed', + }; +} + +test('incoming issues and Rocky health fail independently without fabricating counts or measurements', async (t) => { + t.mock.method(Date, 'now', () => NOW); + for (const failed of ['ingress', 'rocky']) { + const response = await handleAutomation( + env(async (url) => { + const host = url.endsWith('/rocky'); + if ((failed === 'rocky') === host) throw new Error('private-upstream-detail'); + return json(host ? rockySnapshot() : snapshot()); + }), + ); + const result = await response.json(); + assert.equal(response.status, 200); + assert.equal(result.state, failed === 'ingress' ? 'unknown' : 'ok'); + assert.equal(result.rocky.state, failed === 'rocky' ? 'unknown' : 'ok'); + if (failed === 'ingress') { + assert.equal(result.data, null); + assert.equal(result.rocky.data.cpu.usagePercent, 12.5); + assert.equal(result.rocky.data.broker.process, 'unknown'); + assert.equal(result.rocky.data.brokerResponse, 'not_probed'); + assert.equal(result.rocky.data.model, 'not_probed'); + } else { + assert.equal(result.data.sources.sentry.count, 1); + assert.equal(result.rocky.data, null); + assert.equal(result.rocky.checkedAt, null); + } + assert.doesNotMatch(JSON.stringify(result), /private-upstream|Bearer/); + } +}); + +test('invalid or stale Rocky measurements cannot overwrite a valid ingress summary', async (t) => { + t.mock.method(Date, 'now', () => NOW); + for (const payload of [ + { ...rockySnapshot(), checkedAt: NOW - 121_000 }, + { ...rockySnapshot(), memory: { ...rockySnapshot().memory, usedBytes: 0 } }, + { ...rockySnapshot(), broker: { ...rockySnapshot().broker, credentials: 'private-broker-token' } }, + { ...rockySnapshot(), model: 'healthy' }, + ]) { + const response = await handleAutomation(env(async (url) => json(url.endsWith('/rocky') ? payload : snapshot()))); + const result = await response.json(); + assert.equal(result.state, 'ok'); + assert.equal(result.data.sources.sentry.count, 1); + assert.equal(result.rocky.state, 'unknown'); + assert.equal(result.rocky.data, null); + assert.doesNotMatch(JSON.stringify(result), /private-broker/); + } +}); diff --git a/apps/attendance/tsconfig.json b/apps/attendance/tsconfig.json index 817dd5d8..783e3f63 100644 --- a/apps/attendance/tsconfig.json +++ b/apps/attendance/tsconfig.json @@ -11,5 +11,5 @@ "erasableSyntaxOnly": true, "allowImportingTsExtensions": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "worker-configuration.d.ts"] } diff --git a/apps/attendance/worker-configuration.d.ts b/apps/attendance/worker-configuration.d.ts new file mode 100644 index 00000000..2e8c08ec --- /dev/null +++ b/apps/attendance/worker-configuration.d.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: f10478503a80b9bd2db903e0b46e7468) +interface __BaseEnv_Env { + ATTENDANCE: KVNamespace; + ERROR_AUTOMATION: Fetcher /* bcsd-error-automation */; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +declare module "*.txt" { + const value: string; + export default value; +} +declare module "*.html" { + const value: string; + export default value; +} +declare module "*.sql" { + const value: string; + export default value; +} +declare module "*.bin" { + const value: ArrayBuffer; + export default value; +} +declare module "*.wasm" { + const value: WebAssembly.Module; + export default value; +} +declare module "*.wasm?module" { + const value: WebAssembly.Module; + export default value; +} \ No newline at end of file diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index cbc04d7b..1fe3d5a8 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -8,4 +8,5 @@ "observability": { "enabled": true }, // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], + "services": [{ "binding": "ERROR_AUTOMATION", "service": "bcsd-error-automation" }], } diff --git a/apps/error-automation/README.md b/apps/error-automation/README.md index 10bc942c..deb68d21 100644 --- a/apps/error-automation/README.md +++ b/apps/error-automation/README.md @@ -43,12 +43,17 @@ pnpm --dir apps/error-automation exec wrangler secret put GITHUB_WEBHOOK_SECRET pnpm --dir apps/error-automation exec wrangler secret put AUTOFIX_EXECUTOR_TOKEN # 모델 upstream: Rocky omp 게이트웨이. VPC 바인딩 `OMP_GATEWAY`(wrangler.jsonc)와 이 bearer가 함께 있어야 추론이 가능하다. pnpm --dir apps/error-automation exec wrangler secret put OMP_GATEWAY_TOKEN +# 읽기 전용 모니터: ERROR_MONITOR_TOKEN은 attendance와만, OMP_GATEWAY_MONITOR_TOKEN은 Rocky와만 공유한다. +pnpm --dir apps/error-automation exec wrangler secret put ERROR_MONITOR_TOKEN +pnpm --dir apps/error-automation exec wrangler secret put OMP_GATEWAY_MONITOR_TOKEN ``` `SENTRY_CLIENT_SECRET`는 Sentry 웹훅 서명, `SENTRY_READ_TOKEN`은 Sentry canonical issue/event 조회에만 사용한다. `CLOUDFLARE_WEBHOOK_SECRET`는 Issues Generic Webhook의 `cf-webhook-auth`와 같은 32자 이상 전용 무작위 값이다. `CLOUDFLARE_DIAGNOSTICS_TOKEN`은 BCSD 한 계정의 Workers Observability Write(공식 조회 API도 이 권한 요구)와 **koin-web/koin-api 두 Worker만** Individual Workers Content Read-Only를 가진 별도 토큰이다. 배포/쓰기 명령에는 사용하지 않고, 고정 Cloudflare API 호스트의 승인된 두 서비스 증거 조회에만 사용한다. `DIAGNOSTICS_READ_TOKEN`은 허용 scope의 진단 GET 전용 32자 이상 값이다. `AUTOFIX_EXECUTOR_TOKEN`은 별도 32자 이상 값으로 **Rocky 전용 실행기와 Worker만** 공유한다. 다른 서비스의 광범위한 제어 토큰을 재사용하지 않고, 해당 호스트에 외부 인바운드 포트를 열지 않는다. Worker는 provider API 키를 갖지 않으며 `ANTHROPIC_API_KEY` 경로는 제거되었다. `OMP_GATEWAY`/`OMP_GATEWAY_TOKEN`이 **유일한** 모델 upstream이다. `OMP_GATEWAY`는 Workers VPC 서비스 `koin-omp-gateway`(`01a1bdd6-d594-76d1-9898-d116f61b6be7`) 바인딩이며, Cloudflare 터널 `koin-omp-gateway-rocky`를 거쳐 Rocky의 `127.0.0.1:43129` 게이트웨이(`scripts/autofix/local-omp-gateway.mjs`, 인증은 Rocky omp auth-broker)로만 연결된다. 공개 hostname은 없다. 둘 다 없으면 접수·진단·큐는 그대로 동작하고 모델 호출만 `omp gateway not configured`로 실패한다. 둘 중 하나라도 있으면 둘 다 유효해야 한다: 바인딩은 `fetch`를 가진 서비스이고, 토큰은 헤더에 안전한 32~512자다. 잘못되거나 반쪽만 있는 설정은 `configuration()`에서 503으로 닫힌다. Worker는 고정 model/system/tools/max_tokens 본문을 바인딩의 `POST /v1/messages`에 `Authorization: Bearer `으로만 보내고(`x-api-key`·`anthropic-version` 없음), 리디렉션·응답 모델 불일치(`anthropic/claude-…` 같은 provider 접두 포함)·비정상 상태는 그대로 실패다. 게이트웨이 실패 뒤 다른 upstream·계정·키로 재시도하지 않는다. 토큰은 다른 secret과 함께 진단·이슈 본문에서 마스킹된다. 이 bearer는 provider 키가 아니라 게이트웨이 접근 토큰이며 실행기·후보 소스·Docker에 전달하지 않는다. Workers Builds 토큰에는 VPC 바인딩을 위한 `Connectivity Directory Bind`가 있어야 한다. +읽기 전용 모니터는 동일 바인딩의 고정 `GET /healthz`를 별도 `OMP_GATEWAY_MONITOR_TOKEN`으로 조회한다. Worker의 `GET /healthz`는 수신 저장소 요약, `GET /healthz/rocky`는 호스트 관측이며 두 경로 모두 `ERROR_MONITOR_TOKEN`으로 인증한다. 수신 요약은 VPC 없이도 동작하고 호스트 조회는 모델·실행기 자격 증명을 필요로 하지 않는다. CPU/RAM·systemd 프로세스 관측과 모델 정상 여부를 혼동하지 않으며 상세 설정·비공개 연결·기한·미확인 상태는 [읽기 전용 모니터 운영 계약](../../scripts/autofix/README.md#읽기-전용-수신-상태rocky-헬스)을 따른다. + 기존 `GITHUB_DISPATCH_APP_*`은 단일 고정 App의 설정명이다. 일반 read 토큰(contents/issues/pull requests read)과 발행 write 토큰(contents/issues/pull requests write)은 기존 범위를 유지한다. **자동 merge 판정 전용** `merge-evidence` 토큰만 contents/pull requests/checks/statuses read를 요청한다. 조직 소유자가 아직 Checks/Commit statuses read 권한 변경을 승인하지 않았다면 이 토큰 발급 거절은 리뷰 발행을 막지 않고 `App 권한(checks/statuses) 승인 대기` 사유로 merge만 건너뛴다. App에는 Pull requests write, Contents write, Checks read, Commit statuses read와 Pull request webhook 이벤트가 필요하다. 키와 토큰은 모델·실행기·DO 저장소에 전달하지 않는다. `GITHUB_WEBHOOK_SECRET`은 App webhook과 일치하는 32자 이상 전용 secret이다. Worker의 외부 HTTP 호출은 `redirect: "manual"`과 비정상 상태 거부를 함께 사용합니다. workerd는 `"error"` 모드를 지원하지 않으며, 자동 리디렉션으로 GitHub·모델 자격 증명을 다른 호스트에 보내지 않습니다. 접수 실패 로그에는 단계·고정 오류 분류·코드 위치만, 모델 거부에는 상태·오류 종류·고정 분류만 남기고 원문 요청/응답과 키는 기록하지 않습니다. diff --git a/apps/error-automation/package.json b/apps/error-automation/package.json index daba39cd..d028f66f 100644 --- a/apps/error-automation/package.json +++ b/apps/error-automation/package.json @@ -5,11 +5,12 @@ "scripts": { "dev": "wrangler dev", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/webhook.test.mjs test/workflow.test.mjs test/queue.test.mjs test/model.test.mjs test/pull-request.test.mjs", + "test": "node --experimental-strip-types --test test/webhook.test.mjs test/workflow.test.mjs test/queue.test.mjs test/model.test.mjs test/pull-request.test.mjs test/monitor.test.mjs", "smoke:local": "node test/smoke-local.mjs", "deploy": "wrangler deploy" }, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31" + "@jridgewell/trace-mapping": "^0.3.31", + "@koin/telemetry": "workspace:*" } } diff --git a/apps/error-automation/src/index.ts b/apps/error-automation/src/index.ts index e956d107..fbbfe9bd 100644 --- a/apps/error-automation/src/index.ts +++ b/apps/error-automation/src/index.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { parseErrorMonitorSnapshot, parseRockyMonitorSnapshot, readMonitorJson } from '@koin/telemetry/monitor'; import { DiagnosticProviderError, trustedDiagnostics } from './diagnostics'; import { SourceMapEvidenceError } from './source-maps'; import { gatewayTransport, modelRateLimit, parseModelRequest } from './model'; @@ -444,6 +445,8 @@ export function redact(value: unknown, env: Env): string { env.SENTRY_READ_TOKEN, env.DIAGNOSTICS_READ_TOKEN, env.AUTOFIX_EXECUTOR_TOKEN, + env.ERROR_MONITOR_TOKEN, + env.OMP_GATEWAY_MONITOR_TOKEN, ]) { if (secret) text = text.replaceAll(secret, '[REDACTED]'); } @@ -594,15 +597,94 @@ function cloudflarePayload(raw: unknown, config: Configuration, env: Env): Diagn ); } -async function verifySecret(value: string | null, secret: string): Promise { - if (!value || value.length > 4096 || !secret) throw new RequestError(401, 'Missing or invalid authentication.'); +async function sameSecret(value: string, secret: string): Promise { const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [ 'sign', 'verify', ]); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(secret)); - if (!(await crypto.subtle.verify('HMAC', key, signature, encoder.encode(value)))) { + return crypto.subtle.verify('HMAC', key, signature, encoder.encode(value)); +} + +async function verifySecret(value: string | null, secret: string): Promise { + if (!value || value.length > 4096 || !secret || !(await sameSecret(value, secret))) throw new RequestError(401, 'Missing or invalid authentication.'); +} + +/** 인증·고정 경로 검증은 저장소와 VPC 접속보다 먼저 끝냅니다. */ +async function authorizeMonitoring(request: Request, env: Env, url: URL): Promise { + if (!env.ERROR_MONITOR_TOKEN || env.ERROR_MONITOR_TOKEN.length < 32) + throw new RequestError(503, 'Read-only monitoring is not configured.'); + const authorization = request.headers.get('Authorization'); + await verifySecret(authorization?.startsWith('Bearer ') ? authorization.slice(7) : null, env.ERROR_MONITOR_TOKEN); + if (request.method !== 'GET') + return new Response('Use GET.', { status: 405, headers: { Allow: 'GET', 'Cache-Control': 'no-store' } }); + if (url.search) throw new RequestError(400, 'Monitoring query parameters are not supported.'); + return null; +} + +/** 수신 요약은 Rocky 연결 실패와 독립적으로 저장된 이슈만 읽습니다. */ +async function monitoringHealth(request: Request, env: Env, url: URL): Promise { + const denied = await authorizeMonitoring(request, env, url); + if (denied) return denied; + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => reject(new RequestError(503, 'Monitoring storage timed out.')), 3000); + try { + const result = await Promise.race([ + env.AUTOFIX_QUEUE.getByName('bcsdlab/bcsd_united').monitorSnapshot(Date.now()), + deadline, + ]); + const snapshot = parseErrorMonitorSnapshot(result, Date.now()); + if (!snapshot) throw new RequestError(503, 'Monitoring snapshot is unavailable or stale.'); + return reply(200, { ...snapshot }, null); + } catch (error) { + if (error instanceof RequestError) throw error; + throw new RequestError(503, 'Monitoring snapshot is unavailable.'); + } finally { + clearTimeout(timer); + } +} + +/** 기존 VPC의 고정 healthz만 읽으며 모델용 Bearer나 공개 URL을 사용하지 않습니다. */ +async function rockyMonitoringHealth(request: Request, env: Env, url: URL): Promise { + const denied = await authorizeMonitoring(request, env, url); + if (denied) return denied; + const gateway = env.OMP_GATEWAY; + const token = env.OMP_GATEWAY_MONITOR_TOKEN; + if (!gateway || !token || !/^[A-Za-z0-9_-]{32,512}$/.test(token)) + throw new RequestError(503, 'Rocky read-only monitoring is not configured.'); + for (const privileged of [env.OMP_GATEWAY_TOKEN, env.AUTOFIX_EXECUTOR_TOKEN, env.ERROR_MONITOR_TOKEN]) { + if (privileged && (await sameSecret(token, privileged))) + throw new RequestError(503, 'Rocky read-only monitoring requires a separate credential.'); + } + const controller = new AbortController(); + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + controller.abort(); + reject(new RequestError(503, 'Rocky monitoring timed out.')); + }, 3000); + try { + const task = async (): Promise => { + const response = await gateway.fetch('http://omp-gateway/healthz', { + method: 'GET', + redirect: 'manual', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (response.status !== 200 || response.redirected) { + void response.body?.cancel().catch(() => {}); + throw new RequestError(503, 'Rocky monitoring is unavailable.'); + } + const snapshot = parseRockyMonitorSnapshot(await readMonitorJson(response, controller.signal), Date.now()); + if (!snapshot) throw new RequestError(503, 'Rocky monitoring is unavailable or stale.'); + return reply(200, { ...snapshot }, null); + }; + return await Promise.race([task(), deadline]); + } catch (error) { + if (error instanceof RequestError) throw error; + throw new RequestError(503, 'Rocky monitoring is unavailable.'); + } finally { + clearTimeout(timer); } } @@ -932,6 +1014,8 @@ export default { if (url.protocol !== 'https:') throw new RequestError(400, 'HTTPS is required.'); if (env.ENVIRONMENT !== 'production') throw new RequestError(503, 'Only the fixed production installation is enabled.'); + if (url.pathname === '/healthz') return await monitoringHealth(request, env, url); + if (url.pathname === '/healthz/rocky') return await rockyMonitoringHealth(request, env, url); if (url.pathname.startsWith('/executor/')) return await executorRequest(request, env, url); const retrieval = url.pathname.startsWith('/diagnostics/'); if (!retrieval && !['/webhooks/sentry', '/webhooks/cloudflare', '/webhooks/github'].includes(url.pathname)) diff --git a/apps/error-automation/src/queue.ts b/apps/error-automation/src/queue.ts index 061b175d..718eaf21 100644 --- a/apps/error-automation/src/queue.ts +++ b/apps/error-automation/src/queue.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { parseErrorMonitorSnapshot, type ErrorMonitorSnapshot } from '@koin/telemetry/monitor'; import { autoMergePullRequest, type AutoMergeOutcome } from './auto-merge'; import { prepare } from './prepare'; import { redact } from './index'; @@ -71,7 +72,7 @@ const INPUT_OVERHEAD_TOKENS = 1024; const MAX_PENDING = 100; const encoder = new TextEncoder(); -type Status = 'queued' | 'preparing' | 'leased' | 'verifying' | 'reporting' | 'publishing' | 'completed' | 'failed'; +type Status = ErrorMonitorSnapshot['recent'][number]['status']; interface Row extends Record { run_id: string; identity_key: string; @@ -91,6 +92,19 @@ interface Row extends Record { model_input_tokens: number; model_output_tokens: number; } + +interface MonitorRow extends Record { + run_id: string | null; + source: 'sentry' | 'cloudflare' | null; + app: 'web' | 'api' | null; + status: Status | null; + enqueued_at: number | null; + sentry_count: number; + sentry_latest_at: number | null; + cloudflare_count: number; + cloudflare_latest_at: number | null; + invalid_count: number; +} export interface Claim { version: 1; runId: string; @@ -248,6 +262,88 @@ export class AutofixQueue extends DurableObject { if (columns.has('issue_number')) ctx.storage.sql.exec('ALTER TABLE jobs DROP COLUMN issue_number'); } + /** 처음 등록된 오류 identity만 집계한다. 조회는 만료 처리·알람·작업 상태를 건드리지 않는다. */ + monitorSnapshot(now: number): ErrorMonitorSnapshot { + if (!Number.isSafeInteger(now) || now < 24 * 60 * 60_000) throw new Error('Invalid monitor time'); + const windowStart = now - 24 * 60 * 60_000; + const rows = this.ctx.storage.sql + .exec( + `WITH registered AS ( + SELECT run_id, status, enqueued_at, + json_extract(identity, '$.source') AS source, + json_extract(identity, '$.app') AS app, + json_extract(identity, '$.scope.workerName') AS worker_name, + json_type(identity, '$.scope.workerName') AS worker_type + FROM jobs + WHERE enqueued_at BETWEEN ? AND ? + AND (identity_key LIKE 'sentry/%' OR identity_key LIKE 'cloudflare/%') + ), classified AS ( + SELECT *, + CASE WHEN source = 'sentry' AND app = 'web' THEN 0 + WHEN source = 'cloudflare' AND app IN ('web', 'api') AND worker_type = 'text' AND worker_name != '' THEN 0 + ELSE 1 END AS invalid + FROM registered + ), eligible AS ( + SELECT run_id, source, app, status, enqueued_at, invalid + FROM classified + WHERE invalid = 1 OR source = 'sentry' OR worker_name != 'koin-api' + ), totals AS ( + SELECT count(CASE WHEN source = 'sentry' THEN 1 END) AS sentry_count, + max(CASE WHEN source = 'sentry' THEN enqueued_at END) AS sentry_latest_at, + count(CASE WHEN source = 'cloudflare' THEN 1 END) AS cloudflare_count, + max(CASE WHEN source = 'cloudflare' THEN enqueued_at END) AS cloudflare_latest_at, + coalesce(sum(invalid), 0) AS invalid_count + FROM eligible + ), recent AS ( + SELECT run_id, source, app, status, enqueued_at + FROM eligible + ORDER BY enqueued_at DESC, run_id DESC + LIMIT 10 + ) + SELECT recent.*, totals.* + FROM totals LEFT JOIN recent ON 1 = 1 + ORDER BY recent.enqueued_at DESC, recent.run_id DESC`, + windowStart, + now, + ) + .toArray(); + const snapshot: ErrorMonitorSnapshot = { + version: 1, + scope: 'ingress-only', + checkedAt: now, + windowStart, + worker: 'responding', + sources: { + sentry: { count: 0, latestAt: null }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [], + }; + const totals = rows[0]; + if (totals.invalid_count !== 0) throw new Error('Invalid monitor identity'); + snapshot.sources.sentry = { count: totals.sentry_count, latestAt: totals.sentry_latest_at }; + snapshot.sources.cloudflare = { count: totals.cloudflare_count, latestAt: totals.cloudflare_latest_at }; + for (const row of rows) { + if (row.run_id === null) continue; + if ( + (row.source !== 'sentry' && row.source !== 'cloudflare') || + row.app === null || + row.status === null || + row.enqueued_at === null + ) + throw new Error('Invalid monitor identity'); + snapshot.recent.push({ + id: row.run_id, + source: row.source, + app: row.app, + registeredAt: row.enqueued_at, + status: row.status, + }); + } + if (!parseErrorMonitorSnapshot(snapshot, now)) throw new Error('Invalid monitor snapshot'); + return snapshot; + } + private async admit( identity: QueueIdentity, key: string, diff --git a/apps/error-automation/src/types.ts b/apps/error-automation/src/types.ts index 54939096..b815d04d 100644 --- a/apps/error-automation/src/types.ts +++ b/apps/error-automation/src/types.ts @@ -145,11 +145,15 @@ export interface Env { CLOUDFLARE_SOURCES: string; CLOUDFLARE_DIAGNOSTICS_TOKEN?: string; DIAGNOSTICS_READ_TOKEN: string; + /** 수신 이슈 요약 전용 Bearer. 모델·실행기·진단·태그 토큰과 재사용하지 않습니다. */ + ERROR_MONITOR_TOKEN?: string; ERROR_PROJECT_DOMAINS: string; ERROR_SOURCE_BRANCHES: string; /** The only model upstream: Workers VPC service binding to the Rocky omp gateway, paired with its bearer. */ OMP_GATEWAY?: Fetcher; OMP_GATEWAY_TOKEN?: string; + /** Rocky 읽기 전용 healthz 전용 Bearer. 모델·실행기·Worker 수신 요약 토큰과 분리합니다. */ + OMP_GATEWAY_MONITOR_TOKEN?: string; GITHUB_OWNER: string; GITHUB_REPOSITORY: string; GITHUB_DISPATCH_APP_ID: string; diff --git a/apps/error-automation/test/monitor.test.mjs b/apps/error-automation/test/monitor.test.mjs new file mode 100644 index 00000000..f5fa3d88 --- /dev/null +++ b/apps/error-automation/test/monitor.test.mjs @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; + +const platform = + 'data:text/javascript,' + + encodeURIComponent('export class DurableObject { constructor(ctx,env) { this.ctx=ctx; this.env=env; } }'); +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') return { url: platform, shortCircuit: true }; + if ( + specifier.startsWith('./') && + context.parentURL?.includes('/apps/error-automation/src/') && + !specifier.endsWith('.ts') + ) + return nextResolve(specifier + '.ts', context); + return nextResolve(specifier, context); + }, +}); +const { default: worker } = await import('../src/index.ts'); +const { parseRockyMonitorSnapshot } = await import('@koin/telemetry/monitor'); +const monitorToken = 'read-only-monitor-' + 'm'.repeat(40); +const executorToken = 'executor-only-' + 'e'.repeat(40); +function harness(read) { + let reads = 0; + let forbidden = 0; + const never = () => { + forbidden++; + throw new Error('Privileged operation must not run'); + }; + const env = { + ENVIRONMENT: 'production', + ERROR_MONITOR_TOKEN: monitorToken, + AUTOFIX_EXECUTOR_ID: 'koin-rocky-umpc', + AUTOFIX_EXECUTOR_TOKEN: executorToken, + OMP_GATEWAY: { fetch: never }, + ERROR_ISSUES: { get: never, idFromName: never }, + AUTOFIX_QUEUE: { + getByName() { + return { + async monitorSnapshot(now) { + reads++; + if (read) return read(now); + return { + version: 1, + scope: 'ingress-only', + worker: 'responding', + checkedAt: now, + windowStart: now - 86400000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }; + }, + claim: never, + enqueue: never, + errorStatus: never, + }; + }, + }, + }; + function request(path = '/healthz', token = monitorToken, method = 'GET') { + return worker.fetch( + new Request('https://automation.example' + path, { + method, + headers: { + ...(token ? { Authorization: 'Bearer ' + token } : {}), + 'X-Executor-Id': 'koin-rocky-umpc', + 'Content-Type': 'application/json', + }, + }), + env, + ); + } + return { env, request, reads: () => reads, forbidden: () => forbidden }; +} + +test('monitor authentication is separate from executor access and runs without model configuration', async () => { + const h = harness(); + assert.equal((await h.request('/healthz', null)).status, 401); + assert.equal((await h.request('/healthz', executorToken)).status, 401); + assert.equal(h.reads(), 0); + const response = await h.request(); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const body = await response.json(); + assert.equal(body.scope, 'ingress-only'); + assert.ok(!('executor' in body) && !('cpu' in body) && !('ram' in body)); + assert.equal((await h.request('/executor/claim', monitorToken, 'POST')).status, 401); + assert.equal(h.forbidden(), 0); +}); + +test('monitor credentials and exact GET path are required before reading storage', async () => { + const h = harness(); + assert.equal((await h.request('/healthz', monitorToken, 'POST')).status, 405); + assert.equal((await h.request('/healthz?url=https://untrusted.example')).status, 400); + assert.equal(h.reads(), 0); + h.env.ERROR_MONITOR_TOKEN = undefined; + assert.equal((await h.request()).status, 503); + assert.equal(h.reads(), 0); +}); + +test('unavailable or unexpectedly detailed monitoring data cannot be reported as healthy', async () => { + const secret = 'private-provider-detail'; + const h = harness(() => { + throw new Error(secret); + }); + const response = await h.request(); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes(secret)); + const stale = harness((now) => ({ + version: 1, + scope: 'ingress-only', + worker: 'responding', + checkedAt: now - 121000, + windowStart: now - 121000 - 86400000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + })); + assert.equal((await stale.request()).status, 503); + assert.equal(h.forbidden(), 0); +}); + +test('monitoring storage has a bounded deadline without invoking privileged work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const started = Promise.withResolvers(); + const stopped = Promise.withResolvers(); + const h = harness(() => { + started.resolve(); + return stopped.promise; + }); + const pending = h.request(); + await started.promise; + t.mock.timers.tick(3000); + assert.equal((await pending).status, 503); + assert.equal(h.forbidden(), 0); +}); + +const rockyToken = 'rocky-monitor-' + 'r'.repeat(40); +function rockySnapshot(now = Date.now()) { + const unit = { configured: true, state: 'active', process: 'running', checkedAt: now }; + return { + version: 1, + scope: 'rocky-read-only', + checkedAt: now, + gateway: 'responding', + cpu: { usagePercent: 0, sampleStart: now - 200, sampleEnd: now }, + memory: { totalBytes: 8192, availableBytes: 6144, usedBytes: 2048, checkedAt: now }, + executor: { ...unit }, + broker: { ...unit }, + brokerResponse: 'not_probed', + model: 'not_probed', + }; +} + +test('Rocky monitoring rejects unauthorized and parameterized requests before private host access', async () => { + const h = harness(); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + for (const token of [null, executorToken, rockyToken]) + assert.equal((await h.request('/healthz/rocky', token)).status, 401); + assert.equal((await h.request('/healthz/rocky', monitorToken, 'POST')).status, 405); + assert.equal((await h.request('/healthz/rocky?url=https://attacker.example')).status, 400); + assert.equal(h.forbidden(), 0); + assert.equal(h.reads(), 0); +}); + +test('Rocky health is reachable only through the pinned VPC path with a role-separated credential', async () => { + const h = harness(); + h.env.OMP_GATEWAY_TOKEN = 'model-' + 'p'.repeat(40); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + let requests = 0; + h.env.OMP_GATEWAY.fetch = async (url, options) => { + requests++; + assert.equal(url, 'http://omp-gateway/healthz'); + assert.equal(options.method, 'GET'); + assert.equal(options.redirect, 'manual'); + assert.equal(options.headers.Authorization, 'Bearer ' + rockyToken); + assert.notEqual(options.headers.Authorization, 'Bearer ' + h.env.OMP_GATEWAY_TOKEN); + return Response.json(rockySnapshot()); + }; + const response = await h.request('/healthz/rocky'); + assert.equal(response.status, 200); + const snapshot = await response.json(); + assert.equal(snapshot.gateway, 'responding'); + assert.equal(snapshot.brokerResponse, 'not_probed'); + assert.equal(snapshot.model, 'not_probed'); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(requests, 1); + assert.equal(h.reads(), 0); + assert.equal(h.forbidden(), 0); + for (const token of [undefined, monitorToken, executorToken, h.env.OMP_GATEWAY_TOKEN]) { + h.env.OMP_GATEWAY_MONITOR_TOKEN = token; + assert.equal((await h.request('/healthz/rocky')).status, 503); + } + assert.equal(requests, 1); + assert.equal((await h.request()).status, 200); +}); + +test('Rocky failures, redirects, stale samples and sensitive extra fields never become healthy host data', async () => { + const h = harness(); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + for (const response of [ + () => Response.redirect('https://attacker.example', 302), + () => new Response('private-broker-error', { status: 500 }), + () => Response.json(rockySnapshot(Date.now() - 121_000)), + () => Response.json({ ...rockySnapshot(), credentials: 'private-broker-token' }), + () => new Response('x', { headers: { 'content-type': 'application/json', 'content-length': '16385' } }), + ]) { + h.env.OMP_GATEWAY.fetch = async () => response(); + const reply = await h.request('/healthz/rocky'); + assert.equal(reply.status, 503); + assert.doesNotMatch(await reply.text(), /private-broker/); + assert.equal((await h.request()).status, 200); + } + assert.equal(h.forbidden(), 0); +}); + +test('Rocky health deadline bounds a stalled response body and aborts the private request', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const started = Promise.withResolvers(); + const h = harness(); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + let aborted = false; + h.env.OMP_GATEWAY.fetch = async (_url, options) => { + options.signal.addEventListener('abort', () => { + aborted = true; + }); + started.resolve(); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(123)); + }, + }), + { + headers: { 'content-type': 'application/json' }, + }, + ); + }; + const pending = h.request('/healthz/rocky'); + await started.promise; + t.mock.timers.tick(3000); + assert.equal((await pending).status, 503); + assert.equal(aborted, true); + assert.equal(h.forbidden(), 0); +}); + +test('Rocky parser distinguishes measured idle, missing measurements and unprobed broker/model health', () => { + const now = Date.now(); + const snapshot = rockySnapshot(now); + assert.deepEqual(parseRockyMonitorSnapshot(snapshot, now), snapshot); + const unknownUnit = { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }; + const unknown = { ...snapshot, cpu: null, memory: null, executor: unknownUnit, broker: unknownUnit }; + assert.deepEqual(parseRockyMonitorSnapshot(unknown, now), unknown); + for (const invalid of [ + { ...snapshot, brokerResponse: 'responding' }, + { ...snapshot, model: 'healthy' }, + { ...snapshot, cpu: { ...snapshot.cpu, sampleEnd: now - 200 } }, + { ...snapshot, cpu: { ...snapshot.cpu, usagePercent: 101 } }, + { ...snapshot, memory: { ...snapshot.memory, usedBytes: 0 } }, + { ...snapshot, memory: { ...snapshot.memory, availableBytes: 8193 } }, + { ...snapshot, executor: { ...snapshot.executor, configured: false } }, + { ...snapshot, broker: { ...snapshot.broker, checkedAt: now - 121_000 } }, + { ...snapshot, broker: { ...snapshot.broker, checkedAt: now + 1 } }, + { ...snapshot, broker: { ...snapshot.broker, raw: 'private state' } }, + ]) + assert.equal(parseRockyMonitorSnapshot(invalid, now), null); +}); diff --git a/apps/error-automation/test/queue.test.mjs b/apps/error-automation/test/queue.test.mjs index c231bf7a..5ebf1eda 100644 --- a/apps/error-automation/test/queue.test.mjs +++ b/apps/error-automation/test/queue.test.mjs @@ -20,6 +20,7 @@ registerHooks({ }, }); const { AutofixQueue } = await import('../src/queue.ts'); +const { parseErrorMonitorSnapshot } = await import('../../../packages/telemetry/src/monitor.ts'); const identity = { source: 'sentry', sourceId: '123', @@ -309,3 +310,224 @@ test('issue-free schema migration preserves prior identity tombstones and PR rec assert.equal(receipt.prUrl, 'https://github.com/BCSDLab/BCSD_united/pull/99'); assert.equal(receipt.reason, null); }); + +test('monitor snapshot counts registered identities in 24h without exposing job payloads or changing state', async (t) => { + const h = harness(t); + const now = Date.now(); + const start = now - 24 * 60 * 60_000; + const secret = 'private-source-id-and-diagnostic'; + const insert = h.db.prepare( + 'INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at, reason, prepared) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + const add = (digit, source, app, workerName, registeredAt, status = 'queued') => { + const rowIdentity = { + source, + sourceId: `${secret}-${digit}`, + project: workerName ?? 'koin-united-prod', + app, + scope: + source === 'cloudflare' ? { accountId: 'private', policyId: 'private', workerName } : { organization: 'bcsd' }, + }; + insert.run( + `autofix-${digit.toString(16).padStart(64, '0')}`, + `${source}/${digit}`, + JSON.stringify(rowIdentity), + status, + registeredAt, + `private-reason-${digit}`, + `private-prepared-${digit}`, + ); + }; + assert.equal(await h.queue.enqueue(identity, key), 'queued'); + assert.equal(await h.queue.enqueue(identity, key), 'duplicate'); + h.db.prepare('UPDATE jobs SET enqueued_at = ? WHERE identity_key = ?').run(start, key); + for (let i = 1; i <= 12; i++) add(i, 'sentry', 'web', null, now - i * 1000, i === 1 ? 'failed' : 'completed'); + add(13, 'cloudflare', 'api', 'koin-web-api', now - 500, 'verifying'); + add(14, 'cloudflare', 'web', 'koin-web', now - 700, 'leased'); + add(15, 'cloudflare', 'api', 'koin-api', now - 300); + add(16, 'cloudflare', 'web', 'koin-api', now - 200); + add(17, 'sentry', 'web', null, start - 1); + add(18, 'cloudflare', 'web', 'koin-web', now + 1); + insert.run( + `autofix-${'f'.repeat(64)}`, + 'github-pr/42', + JSON.stringify({ source: 'github-pr', pullNumber: 42 }), + 'queued', + now, + 'private-pr-reason', + 'private-pr-payload', + ); + const before = h.db.prepare('SELECT * FROM jobs ORDER BY run_id').all(); + const changes = h.db.prepare('SELECT total_changes() AS count').get().count; + const snapshot = h.queue.monitorSnapshot(now); + assert.deepEqual(snapshot.sources, { + sentry: { count: 13, latestAt: now - 1000 }, + cloudflare: { count: 2, latestAt: now - 500 }, + }); + assert.equal(snapshot.checkedAt, now); + assert.equal(snapshot.windowStart, start); + assert.equal(snapshot.scope, 'ingress-only'); + assert.equal(snapshot.worker, 'responding'); + assert.equal(snapshot.recent.length, 10); + assert.deepEqual( + snapshot.recent.slice(0, 3).map(({ source, registeredAt, status }) => [source, registeredAt, status]), + [ + ['cloudflare', now - 500, 'verifying'], + ['cloudflare', now - 700, 'leased'], + ['sentry', now - 1000, 'failed'], + ], + ); + assert.equal( + snapshot.recent.every( + (item, index) => + item.registeredAt >= start && (!index || item.registeredAt <= snapshot.recent[index - 1].registeredAt), + ), + true, + ); + assert.equal( + snapshot.recent.every(({ id }) => /^autofix-[a-f0-9]{64}$/.test(id)), + true, + ); + assert.doesNotMatch( + JSON.stringify(snapshot), + /private|sourceId|identity_key|prepared|reason|pr_url|koin-api|github-pr/, + ); + assert.deepEqual(h.db.prepare('SELECT * FROM jobs ORDER BY run_id').all(), before); + assert.equal(h.db.prepare('SELECT total_changes() AS count').get().count, changes); + assert.deepEqual(h.alarms, []); + assert.deepEqual(h.writes, []); + assert.deepEqual(new AutofixQueue(h.queue.ctx, h.queue.env).monitorSnapshot(now), snapshot); +}); + +test('monitor snapshot aggregates a source absent from recent ten and rejects invalid older rows', (t) => { + const h = harness(t); + const now = Date.now(); + const insert = h.db.prepare( + 'INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at) VALUES (?, ?, ?, ?, ?)', + ); + for (let i = 1; i <= 12; i++) { + insert.run( + `autofix-${i.toString(16).padStart(64, '0')}`, + `sentry/${i}`, + JSON.stringify({ ...identity, sourceId: String(i) }), + 'queued', + now - i * 1000, + ); + } + const cloudflareIdentity = { + source: 'cloudflare', + sourceId: 'older', + project: 'koin-web', + app: 'web', + scope: { accountId: 'account', policyId: 'policy', workerName: 'koin-web' }, + }; + insert.run( + `autofix-${'d'.repeat(64)}`, + 'cloudflare/older', + JSON.stringify(cloudflareIdentity), + 'failed', + now - 20_000, + ); + const snapshot = h.queue.monitorSnapshot(now); + assert.deepEqual(snapshot.sources, { + sentry: { count: 12, latestAt: now - 1000 }, + cloudflare: { count: 1, latestAt: now - 20_000 }, + }); + assert.equal(snapshot.recent.length, 10); + assert.equal( + snapshot.recent.every((entry) => entry.source === 'sentry'), + true, + ); + + insert.run( + `autofix-${'e'.repeat(64)}`, + 'cloudflare/invalid', + JSON.stringify({ ...cloudflareIdentity, scope: { accountId: 'account', policyId: 'policy' } }), + 'queued', + now - 30_000, + ); + assert.throws(() => h.queue.monitorSnapshot(now), /Invalid monitor identity/); + h.db.prepare('UPDATE jobs SET identity = ? WHERE identity_key = ?').run('{not json', 'cloudflare/invalid'); + assert.throws(() => h.queue.monitorSnapshot(now)); +}); + +test('monitor snapshot reports observed empty windows, not stale jobs or malformed identities', (t) => { + const h = harness(t); + const now = Date.now(); + assert.deepEqual(h.queue.monitorSnapshot(now).sources, { + sentry: { count: 0, latestAt: null }, + cloudflare: { count: 0, latestAt: null }, + }); + h.db + .prepare('INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at) VALUES (?, ?, ?, ?, ?)') + .run(`autofix-${'a'.repeat(64)}`, 'sentry/bad', '{not json', 'queued', now); + assert.throws(() => h.queue.monitorSnapshot(now)); + h.db.prepare('UPDATE jobs SET identity = ?').run( + JSON.stringify({ + source: 'cloudflare', + sourceId: 'id', + project: 'koin-web', + app: 'web', + scope: { accountId: 'account', policyId: 'policy' }, + }), + ); + assert.throws(() => h.queue.monitorSnapshot(now), /Invalid monitor identity/); + assert.throws(() => h.queue.monitorSnapshot(NaN), /Invalid monitor time/); +}); + +test('shared monitor parser accepts fresh ingress snapshots and rejects stale, unsafe, or inconsistent payloads', () => { + const now = Date.now(); + const snapshot = { + version: 1, + scope: 'ingress-only', + checkedAt: now, + windowStart: now - 24 * 60 * 60_000, + worker: 'responding', + sources: { + sentry: { count: 1, latestAt: now - 10 }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [ + { id: `autofix-${'a'.repeat(64)}`, source: 'sentry', app: 'web', registeredAt: now - 10, status: 'queued' }, + ], + }; + assert.deepEqual(parseErrorMonitorSnapshot(snapshot, now), snapshot); + const shift = (offset) => ({ + ...snapshot, + checkedAt: now + offset, + windowStart: now + offset - 24 * 60 * 60_000, + sources: { ...snapshot.sources, sentry: { count: 1, latestAt: now + offset - 10 } }, + recent: [{ ...snapshot.recent[0], registeredAt: now + offset - 10 }], + }); + assert.deepEqual(parseErrorMonitorSnapshot(shift(-120_000), now), shift(-120_000)); + assert.deepEqual(parseErrorMonitorSnapshot(shift(60_000), now), shift(60_000)); + assert.equal(parseErrorMonitorSnapshot(shift(-120_001), now), null); + assert.equal(parseErrorMonitorSnapshot(shift(60_001), now), null); + assert.equal(parseErrorMonitorSnapshot(snapshot, NaN), null); + const invalid = [ + { ...snapshot, version: 2 }, + { ...snapshot, scope: 'executor-healthy' }, + { ...snapshot, worker: 'healthy' }, + { ...snapshot, checkedAt: Infinity }, + { ...snapshot, windowStart: snapshot.windowStart - 1 }, + { ...snapshot, diagnostic: 'secret' }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: -1, latestAt: now - 10 } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1.5, latestAt: now - 10 } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1, latestAt: null } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1, latestAt: now + 1 } } }, + { ...snapshot, sources: { ...snapshot.sources, cloudflare: { count: 0, latestAt: null, token: 'secret' } } }, + { ...snapshot, sources: { ...snapshot.sources, cloudflare: { count: 1, latestAt: null } } }, + { ...snapshot, recent: [{ ...snapshot.recent[0], status: 'unknown' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], sourceId: 'secret' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], id: 'source-id' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], registeredAt: snapshot.windowStart - 1 }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], registeredAt: now + 1 }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], app: 'api' }] }, + { ...snapshot, recent: Array(11).fill(snapshot.recent[0]) }, + { + ...snapshot, + recent: [snapshot.recent[0], { ...snapshot.recent[0], id: `autofix-${'b'.repeat(64)}`, registeredAt: now - 1 }], + }, + ]; + for (const candidate of invalid) assert.equal(parseErrorMonitorSnapshot(candidate, now), null); +}); diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 1e00570d..4b8cc090 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./monitor": "./src/monitor.ts", "./runtime": { "workerd": "./src/worker.ts", "browser": "./src/browser.ts", diff --git a/packages/telemetry/src/monitor.ts b/packages/telemetry/src/monitor.ts new file mode 100644 index 00000000..92cb2679 --- /dev/null +++ b/packages/telemetry/src/monitor.ts @@ -0,0 +1,272 @@ +export interface ErrorMonitorSnapshot { + version: 1; + scope: 'ingress-only'; + checkedAt: number; + windowStart: number; + worker: 'responding'; + sources: { + sentry: { count: number; latestAt: number | null }; + cloudflare: { count: number; latestAt: number | null }; + }; + recent: { + id: string; + source: 'sentry' | 'cloudflare'; + app: 'web' | 'api'; + registeredAt: number; + status: 'queued' | 'preparing' | 'leased' | 'verifying' | 'reporting' | 'publishing' | 'completed' | 'failed'; + }[]; +} + +const WINDOW_MS = 24 * 60 * 60_000; + +function record(value: unknown, keys: readonly string[]): value is Record { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function timestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function status(value: unknown): value is ErrorMonitorSnapshot['recent'][number]['status'] { + switch (value) { + case 'queued': + case 'preparing': + case 'leased': + case 'verifying': + case 'reporting': + case 'publishing': + case 'completed': + case 'failed': + return true; + default: + return false; + } +} + +/** Reject stale, malformed, or unexpectedly detailed snapshots rather than displaying invented health. */ +function isErrorMonitorSnapshot(value: unknown, now: number): value is ErrorMonitorSnapshot { + if ( + !timestamp(now) || + !record(value, ['version', 'scope', 'checkedAt', 'windowStart', 'worker', 'sources', 'recent']) + ) + return false; + const { checkedAt, windowStart, sources, recent } = value; + if ( + value.version !== 1 || + value.scope !== 'ingress-only' || + value.worker !== 'responding' || + !timestamp(checkedAt) || + !timestamp(windowStart) || + checkedAt - windowStart !== WINDOW_MS || + checkedAt < now - 120_000 || + checkedAt > now + 60_000 || + !record(sources, ['sentry', 'cloudflare']) || + !Array.isArray(recent) || + recent.length > 10 + ) + return false; + + let sentryCount = 0; + let cloudflareCount = 0; + for (const name of ['sentry', 'cloudflare'] as const) { + const source = sources[name]; + if (!record(source, ['count', 'latestAt'])) return false; + if ( + typeof source.count !== 'number' || + !Number.isSafeInteger(source.count) || + source.count < 0 || + (source.latestAt !== null && + (!timestamp(source.latestAt) || source.latestAt < windowStart || source.latestAt > checkedAt)) || + (source.count === 0) !== (source.latestAt === null) + ) + return false; + if (name === 'sentry') sentryCount = source.count; + else cloudflareCount = source.count; + } + + const ids = new Set(); + let previous = checkedAt; + let sentryRecent = 0; + let cloudflareRecent = 0; + for (const entry of recent) { + if ( + !record(entry, ['id', 'source', 'app', 'registeredAt', 'status']) || + typeof entry.id !== 'string' || + !/^autofix-[a-f0-9]{64}$/.test(entry.id) || + ids.has(entry.id) || + (entry.source !== 'sentry' && entry.source !== 'cloudflare') || + (entry.app !== 'web' && entry.app !== 'api') || + (entry.source === 'sentry' && entry.app !== 'web') || + !timestamp(entry.registeredAt) || + entry.registeredAt < windowStart || + entry.registeredAt > previous || + !status(entry.status) + ) + return false; + ids.add(entry.id); + previous = entry.registeredAt; + if (entry.source === 'sentry') sentryRecent++; + else cloudflareRecent++; + } + if (sentryRecent > sentryCount || cloudflareRecent > cloudflareCount) return false; + return true; +} + +export function parseErrorMonitorSnapshot(value: unknown, now: number): ErrorMonitorSnapshot | null { + return isErrorMonitorSnapshot(value, now) ? value : null; +} + +export interface RockyUnitSnapshot { + configured: boolean | null; + state: 'active' | 'inactive' | 'failed' | 'activating' | 'deactivating' | 'unknown'; + process: 'running' | 'stopped' | 'unknown'; + checkedAt: number | null; +} + +export interface RockyMonitorSnapshot { + version: 1; + scope: 'rocky-read-only'; + checkedAt: number; + gateway: 'responding'; + cpu: { usagePercent: number; sampleStart: number; sampleEnd: number } | null; + memory: { totalBytes: number; availableBytes: number; usedBytes: number; checkedAt: number } | null; + executor: RockyUnitSnapshot; + broker: RockyUnitSnapshot; + brokerResponse: 'not_probed'; + model: 'not_probed'; +} + +function freshTimestamp(value: unknown, now: number): value is number { + return timestamp(value) && value >= now - 120_000 && value <= now + 60_000; +} + +function unitSnapshot(value: unknown, now: number): value is RockyUnitSnapshot { + if (!record(value, ['configured', 'state', 'process', 'checkedAt'])) return false; + if ( + (value.configured !== null && typeof value.configured !== 'boolean') || + typeof value.state !== 'string' || + !['active', 'inactive', 'failed', 'activating', 'deactivating', 'unknown'].includes(value.state) || + typeof value.process !== 'string' || + !['running', 'stopped', 'unknown'].includes(value.process) + ) + return false; + if (value.configured === false && (value.state === 'active' || value.process === 'running')) return false; + if (value.checkedAt === null) + return value.configured === null && value.state === 'unknown' && value.process === 'unknown'; + return freshTimestamp(value.checkedAt, now); +} + +function nonnegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isRockyMonitorSnapshot(value: unknown, now: number): value is RockyMonitorSnapshot { + if ( + !timestamp(now) || + !record(value, [ + 'version', + 'scope', + 'checkedAt', + 'gateway', + 'cpu', + 'memory', + 'executor', + 'broker', + 'brokerResponse', + 'model', + ]) || + value.version !== 1 || + value.scope !== 'rocky-read-only' || + value.gateway !== 'responding' || + value.brokerResponse !== 'not_probed' || + value.model !== 'not_probed' || + !freshTimestamp(value.checkedAt, now) || + !unitSnapshot(value.executor, now) || + !unitSnapshot(value.broker, now) + ) + return false; + if ( + (value.executor.checkedAt !== null && value.executor.checkedAt > value.checkedAt) || + (value.broker.checkedAt !== null && value.broker.checkedAt > value.checkedAt) + ) + return false; + const { cpu, memory, checkedAt } = value; + if ( + cpu !== null && + (!record(cpu, ['usagePercent', 'sampleStart', 'sampleEnd']) || + typeof cpu.usagePercent !== 'number' || + !Number.isFinite(cpu.usagePercent) || + cpu.usagePercent < 0 || + cpu.usagePercent > 100 || + !freshTimestamp(cpu.sampleStart, now) || + !freshTimestamp(cpu.sampleEnd, now) || + cpu.sampleEnd <= cpu.sampleStart || + cpu.sampleEnd - cpu.sampleStart > 10_000 || + cpu.sampleEnd > checkedAt) + ) + return false; + if ( + memory !== null && + (!record(memory, ['totalBytes', 'availableBytes', 'usedBytes', 'checkedAt']) || + !nonnegativeInteger(memory.totalBytes) || + memory.totalBytes === 0 || + !nonnegativeInteger(memory.availableBytes) || + !nonnegativeInteger(memory.usedBytes) || + memory.availableBytes > memory.totalBytes || + memory.usedBytes !== memory.totalBytes - memory.availableBytes || + !freshTimestamp(memory.checkedAt, now) || + memory.checkedAt > checkedAt) + ) + return false; + return true; +} + +/** 서비스 활성 상태를 브로커 응답이나 모델 실행 가능 상태로 승격하지 않습니다. */ +export function parseRockyMonitorSnapshot(value: unknown, now: number): RockyMonitorSnapshot | null { + return isRockyMonitorSnapshot(value, now) ? value : null; +} + +/** 바인딩 응답도 헤더·스트림·UTF-8·전체 본문 크기를 검증합니다. */ +export async function readMonitorJson(response: Response, signal: AbortSignal): Promise { + const maxBytes = 16 * 1024; + const length = response.headers.get('content-length'); + if ( + (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) || + !/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '') + ) { + void response.body?.cancel().catch(() => {}); + throw new Error('Invalid monitoring response'); + } + if (!response.body) throw new Error('Missing monitoring response'); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); + let bytes = 0; + let text = ''; + let complete = false; + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener('abort', cancel, { once: true }); + try { + if (signal.aborted) throw new Error('Monitoring deadline exceeded'); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maxBytes) throw new Error('Oversized monitoring response'); + text += decoder.decode(chunk.value, { stream: true }); + } + complete = true; + return JSON.parse(text + decoder.decode()); + } finally { + signal.removeEventListener('abort', cancel); + if (!complete) cancel(); + reader.releaseLock(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57b6c4b..8f904db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,12 @@ importers: specifier: 3.24.6 version: 3.24.6(@types/node@24.19.2) + apps/attendance: + dependencies: + '@koin/telemetry': + specifier: workspace:* + version: link:../../packages/telemetry + apps/b-bot: dependencies: '@koin/b-bot-ai': @@ -332,6 +338,9 @@ importers: '@jridgewell/trace-mapping': specifier: ^0.3.31 version: 0.3.31 + '@koin/telemetry': + specifier: workspace:* + version: link:../../packages/telemetry apps/web: dependencies: diff --git a/scripts/autofix/README.md b/scripts/autofix/README.md index ca1a6fc6..46f0ff22 100644 --- a/scripts/autofix/README.md +++ b/scripts/autofix/README.md @@ -8,6 +8,20 @@ - **Cloudflare Issues:** Worker가 공식 Notifications envelope를 인증하고 계정·정책·Worker·앱을 재검증한다. 불투명한 `data`를 예외/경로/소스 프레임으로 임의 해석하지 않고, 일치하는 Issues occurrence·Tail/Telemetry·고정 소스 버전의 안전한 GET 경로와 매핑된 앱 소스 프레임을 별도 확인한 경우에만 수정 후보를 만든다. 근거가 부족하면 `CLOUDFLARE_EVIDENCE_UNAVAILABLE`로 실패 기록을 남기며 원본 Cloudflare Issue는 변경하지 않는다. 기존 Workers 지표 알림을 Issues 대신 쓰거나 존재하지 않는 비공개 Issues 수정 API를 호출하지 않는다. - 외부 API, 인증된 사용자 상태, dependency/config/generated code 수정, Node 회귀로 재현할 수 없는 오류는 안전 범위 밖이다. 실패를 우회하거나 빈 PR을 만들지 않는다. +## 읽기 전용 수신 상태·Rocky 헬스 + +정확한 HTTPS `GET /healthz`와 `GET /healthz/rocky`는 별도의 `ERROR_MONITOR_TOKEN` Bearer만 인증합니다. 쿼리 파라미터는 받지 않으며 잘못된/없는 토큰은 401, 미설정은 503, 다른 메서드는 405입니다. 이 토큰은 실행기·진단·모델·기기 토큰과 분리합니다. + +`GET /healthz` 응답은 `version: 1`, `scope: ingress-only`, 관측 시각, 24시간 창의 Sentry/Cloudflare 고유 등록 건수·최근 신규 등록 시각·최대 10개 처리 상태뿐입니다. 건수와 최근 시각은 제한 없는 전체 기간 집계이며 최근 목록만 10건으로 제한합니다. `enqueued_at`은 처음 등록된 시각이므로 중복 웹훅 수신량/재전송 시각으로 해석하지 않습니다. GitHub PR 작업과 Cloudflare `koin-api` 기록은 제외합니다. 원본 provider ID, 오류 전문, 진단, 소스, 로그, 토큰, lease, PR URL은 노출하지 않습니다. + +수신 조회는 고정 저장소 큐의 SELECT만 수행하고 알람·claim·모델·GitHub 작업을 실행하지 않습니다. VPC나 모델 자격 증명 없이 동작합니다. 저장소 오류·오래되거나 잘못된 응답은 503이며 0건/정상으로 대체하지 않습니다. 응답은 no-store, 저장소 응답 기한은 3초입니다. + +`GET /healthz/rocky`는 기존 `OMP_GATEWAY` VPC 바인딩으로 고정 `http://omp-gateway/healthz`만 조회합니다. 호스트 전용 `OMP_GATEWAY_MONITOR_TOKEN`을 사용하며 모델용 `OMP_GATEWAY_TOKEN`, 실행기 토큰, 수신용 `ERROR_MONITOR_TOKEN`과 같으면 503으로 닫힙니다. VPC 응답 본문까지 3초·16 KiB로 제한하고 리다이렉트·오래된/알 수 없는 필드를 거부합니다. 실패 원문을 전달하지 않습니다. + +Rocky 응답은 `version: 1`, `scope: rocky-read-only`, 실제 관측 시각, 게이트웨이 응답 여부, CPU 두 표본의 차이, `MemTotal − MemAvailable` RAM 사용량과 고정 실행기·브로커 서비스 상태입니다. 설치/활성/프로세스 실행과 브로커 HTTP 응답·모델 준비 상태는 다릅니다. 미측정 값은 null/unknown이며 `brokerResponse`와 `model`은 `not_probed`입니다. 모델 생성·작업 claim·GitHub 조회는 수행하지 않습니다. + +출근부는 Worker service binding과 서버 secret으로 두 경로를 독립적으로 읽습니다. 정상 main 승격, 두 Worker 배포, Rocky 코드·전용 파일·secret 설정이 활성화 전제입니다. 수신 Worker 응답만으로 Rocky 정상을 주장하지 않으며 호스트 연결 실패도 실제 수신 건수를 0으로 바꾸지 않습니다. 공개 hostname, 새 터널, 사설 SSH 라우트, WARP/OpenBao 권한을 추가하지 않습니다. + ## 고정 제공자 식별자 웹훅은 아래 식별자만 저장하고 고정 저장소 큐에 등록한다. 추가 프롬프트·URL·저장소·호스트·브랜치·명령·파일 경로로 실행 대상을 바꿀 수 없다. @@ -111,10 +125,10 @@ cat /home/user/.home-butler/koin-omp-gateway/omp-gateway.token | pnpm --dir apps 게이트웨이 계약: - `OMP_GATEWAY_TOKEN_FILE`은 현재 uid 소유의 **0600 일반 파일**(symlink 불가)이어야 하며 32–512자 URL-safe 토큰 한 줄을 담는다. 조건이 하나라도 어긋나면 시작하지 않는다. `OMP_GATEWAY_PORT` 기본값은 `43129`다. -- 정확히 `POST /v1/messages`만 받는다(query 포함 다른 경로 404, 다른 메서드 405). bearer는 시간 일정 비교로 먼저 검사하며(실패 401), 인증 전에는 omp를 실행하지 않는다. broker/usage/models 등 다른 게이트웨이 경로는 노출하지 않는다. +- 모델 경로는 정확히 `POST /v1/messages`이며 읽기 전용 관측 경로는 별도 Bearer의 `GET /healthz`다(query 포함 다른 경로 404, 다른 메서드 405). 모델 토큰으로 헬스를 읽거나 모니터 토큰으로 모델을 실행할 수 없다. bearer는 시간 일정 비교로 검사하며(실패 401), 인증 전에는 omp나 호스트 probe를 실행하지 않는다. broker/usage/models 등 다른 게이트웨이 경로는 노출하지 않는다. - 요청 본문은 1 MiB까지(초과 시 즉시 413 후 연결 종료, 나머지 바이트는 버퍼링하지 않음) JSON 객체여야 하고 `model`은 정확히 `claude-sonnet-5-5` 또는 `claude-opus-5-5`, `max_tokens`는 1–8,192만 허용한다. 퍼지 이름·role(`@smol`)·provider 접두사·`stream`·provider 실행 도구(`type`이 `custom`이 아닌 tool)·알 수 없는 필드는 400이다. 브리지는 도구를 실행하지 않으며 본문을 해석·수정하지 않고 `model`만 `anthropic/<같은 id>`로 바꿔 전달한다. - 응답은 요청한 모델이 실제로 답한 `message` envelope(`anthropic/<요청 id>` 또는 bare id)일 때만 200이며 `model`을 요청한 bare id로 정규화한다. 다른 모델·fallback chain이 답하면 502다. omp 상위 429는 `rate_limit_error`로 429를 돌려주되 `Retry-After`는 붙이지 않고(stdio는 헤더를 주지 않음), 그 밖의 상위 실패는 상위 오류 문구 없이 502 `upstream_error`다. omp의 오류 문구와 자식 stderr는 로컬 경로·계정 정보를 담을 수 있어 응답·로그 어디에도 쓰지 않는다. -- 동시 요청은 하나만 처리한다(진행 중이면 503 + `Retry-After: 5`, Worker는 이를 종료 실패로 취급). 요청당 180초 기한은 본문 수신부터 적용되어 본문이 멈추거나 추론이 늦으면 504이며, 기한 초과·클라이언트 연결 종료·종료 신호 때 브리지가 띄운 omp 자식을 SIGKILL하고 슬롯을 즉시 비운다. 자식 stdout은 4 MiB까지만 읽고 초과 시 자식을 종료한다. shell을 거치지 않고 `omp auth-gateway stdio`를 직접 실행한다. +- 모델 요청은 하나만 처리한다(진행 중이면 503 + `Retry-After: 5`, Worker는 이를 종료 실패로 취급). 요청당 180초 기한은 본문 수신부터 적용되어 본문이 멈추거나 추론이 늦으면 504이며, 기한 초과·클라이언트 연결 종료·종료 신호 때 브리지가 띄운 omp 자식을 SIGKILL하고 슬롯을 즉시 비운다. 자식 stdout은 4 MiB까지만 읽고 초과 시 자식을 종료한다. shell을 거치지 않고 `omp auth-gateway stdio`를 직접 실행한다. 헬스 수집·응답 생성은 모델 슬롯과 독립적이며 2초로 제한한다. - 로그는 경로 분류·상태·소요 시간만 남긴다. - **자식 환경과 재시도 차단:** omp 자식은 허용 목록 환경만 받는다(`PATH`/`HOME`/`USER`/`TMPDIR`/`LANG`/`LC_*`/`XDG_*`/`PI_CONFIG_DIR`/`PI_PROXY`/proxy 변수/`NODE_EXTRA_CA_CERTS`/`OMP_AUTH_BROKER_*`). `ANTHROPIC_API_KEY`·`ANTHROPIC_OAUTH_TOKEN`·`ANTHROPIC_BASE_URL`·`CLAUDE_CODE_*`·다른 `*_API_KEY`와 운영자의 `PI_CONFIG_FILES`는 전달되지 않으므로 브리지 환경으로 요청을 다른 endpoint나 key로 돌릴 수 없다. 브리지는 시작 시 0600 임시 overlay(`PI_CONFIG_FILES`)로 `retry.enabled=false`, `maxRetries=0`, `modelFallback=false`, `usageAwareFallback=false`, `waitForUsageReset=false`, `rateLimitWalksChain=false`, 두 모델의 `fallbackChains=[]`를 자식 프로세스에만 적용한다(사용자 전역 `config.yml`은 수정하지 않음). 따라서 Worker가 예산에 계산하는 게이트웨이 호출 한 번은 provider 시도 한 번이며, 같은 모델 재시도·다른 모델 fallback·usage reset 대기는 일어나지 않는다. - **시작 게이트:** 시작 시 `GET /v1/models`(quota 소비 없음)로 `anthropic/claude-sonnet-5-5`와 `anthropic/claude-opus-5-5`가 omp의 자격증명 선택에 따라 실제 서비스되는지 확인하고, 하나라도 없으면 listen하지 않고 종료한다. 로그인 만료 등은 요청 시점에 502로 드러나며 브리지가 로그인을 대신 수행하지 않는다. @@ -122,6 +136,24 @@ cat /home/user/.home-butler/koin-omp-gateway/omp-gateway.token | pnpm --dir apps 제한과 해제: 게이트웨이는 공개 주소가 없고 VPC 바인딩을 가진 Worker만 도달하며, bearer가 추가로 접근을 막는다. 게이트웨이 bearer는 연결 인증용 비밀이며 provider 자격증명이 아니다. 해제할 때는 두 유닛을 내리고 토큰 파일을 삭제한 뒤 Worker의 `OMP_GATEWAY_TOKEN`과 VPC 바인딩을 제거한다. 그 시점부터 새 인증 게이트웨이를 설정할 때까지 **모델 처리는 불가능**하며(모델 호출이 설정 오류로 실패), API key를 다시 등록하는 복구 경로는 없다. 게이트웨이는 Rocky와 omp auth-broker 상태에 의존하고, 모델 호출 외의 어떤 Worker 동작(발행·검증·merge)도 바꾸지 않는다. +### Rocky 읽기 전용 헬스 활성화 + +게이트웨이는 `OMP_GATEWAY_MONITOR_TOKEN_FILE`을 설정하지 않아도 기존 모델 경로로 실행할 수 있으며 헬스만 503이다. 헬스를 활성화할 때는 다음 운영 절차를 따른다. + +1. 실제 `koin-omp-gateway.service`의 `User`/`Group`을 확인하고 작업·모델 요청이 진행 중이지 않은 시점에 호스트 코드를 갱신한다. 저장소 유닛의 실행 계정은 `homebutler`다. +2. 그 uid 소유의 0700 일반 디렉터리에 모델용과 다른 32–512자 URL-safe 무작위 모니터 토큰을 0600 일반 파일로 신규 생성한다. 디렉터리와 파일은 symlink를 허용하지 않으며 기존 모델/브로커 토큰을 복사하거나 덮어쓰지 않는다. +3. 유닛 drop-in에 아래 경로를 설정하고 승인된 작업 창에서 daemon-reload와 게이트웨이 재시작을 수행한다. 터널·브로커·실행기 유닛이나 네트워크 접근 정책은 변경하지 않는다. + + ```ini + [Service] + Environment=OMP_GATEWAY_MONITOR_TOKEN_FILE=/home/user/.home-butler/koin-omp-gateway/monitor.token + ``` + +4. 같은 파일의 값을 안전한 대화형 입력으로 error-automation의 `OMP_GATEWAY_MONITOR_TOKEN` secret에 등록한다. `ERROR_MONITOR_TOKEN`은 attendance와 error-automation 사이의 별도 비밀이다. secret 등록은 즉시 배포를 일으키므로 main 승격과 운영 승인 절차를 따른다. +5. 기존 비공개 경로에서 실제 `/healthz/rocky` 응답과 관측 시각을 확인한다. Linux `/proc/stat`은 약 200ms 차이로 읽으며 guest tick을 중복 합산하지 않고 idle+iowait을 제외한다. 메모리나 고정 `systemctl show` 읽기가 실패하면 해당 값은 미확인이다. 서비스 활성은 실행 중 프로세스·브로커 응답·모델 호출 성공과 동일하지 않다. + +모니터만 중지할 때는 drop-in의 모니터 파일 환경 설정과 Worker의 모니터 전용 secret만 운영 절차로 해제한다. 모델 토큰·VPC·터널을 제거하지 않는다. + ## 기존 PR 검토·수정 모드 (`mode: 'pull-request'`) 같은 Worker 큐와 실행기 lease를 쓰는 별도 `mode:'pull-request'` 작업이다. 수동 `/executor/pull-requests/enqueue`는 `PR_REVIEW_ENABLED==='true'`와 명시적 허용 PR 번호가 필요하다. 자동 경로는 `PR_REVIEW_ENABLED==='true'` + `PR_REVIEW_AUTO==='true'` + 서명된 `POST /webhooks/github`의 opened/reopened/synchronize/ready_for_review 이벤트다. Worker는 canonical PR을 조회해 fork/draft/봇/보호 ref와 자동화 자신이 생성한 수정 head SHA를 제외하고 실제 develop tip과 head를 pin한다. develop보다 뒤처진 head는 write 토큰 확보 후 SHA당 최대 3번 update-branch를 시도한다(일시적 실패만 재시도, 409/422 충돌은 한 marker 댓글로 종결). 성공 후 synchronize를 기다린다. diff --git a/scripts/autofix/koin-omp-gateway.service b/scripts/autofix/koin-omp-gateway.service index b6b8642a..cadc7b4c 100644 --- a/scripts/autofix/koin-omp-gateway.service +++ b/scripts/autofix/koin-omp-gateway.service @@ -14,6 +14,8 @@ Environment=HOME=/home/user/.home-butler/koin-omp-gateway Environment=OMP_AUTH_BROKER_URL=http://127.0.0.1:8766 Environment=PATH=/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin Environment=OMP_GATEWAY_TOKEN_FILE=/home/user/.home-butler/koin-omp-gateway/omp-gateway.token +# 선택 사항: 먼저 homebutler 소유의 0700 디렉터리에 모델 토큰과 다른 0600 모니터 토큰을 마련한다. +# Environment=OMP_GATEWAY_MONITOR_TOKEN_FILE=/home/user/.home-butler/koin-omp-gateway/monitor.token Environment=OMP_GATEWAY_PORT=43129 ExecStart=/usr/bin/node /usr/local/lib/koin-autofix/local-omp-gateway.mjs NoNewPrivileges=yes diff --git a/scripts/autofix/local-omp-gateway.mjs b/scripts/autofix/local-omp-gateway.mjs index fa484399..39802222 100755 --- a/scripts/autofix/local-omp-gateway.mjs +++ b/scripts/autofix/local-omp-gateway.mjs @@ -1,23 +1,20 @@ #!/usr/bin/env node /** - * Loopback bridge that lets the error-automation Worker reach this machine's - * omp credentials for the fixed model request only. It accepts exactly - * `POST /v1/messages` with the dedicated bearer, forwards the body as one - * `omp auth-gateway stdio` request (official credential path, nothing exported) - * and returns the Anthropic Messages envelope back. The bridge never executes - * tools, never logs bodies, upstream errors or the child's stderr, serves one - * request at a time, and kills its own omp child when the client disconnects or - * the deadline passes. + * Rocky의 고정 모델 요청을 로컬 omp 자격 증명으로 전달하는 루프백 브리지. + * `POST /v1/messages`는 모델 토큰으로, 선택적 `GET /healthz`는 별도 모니터 토큰으로 인증한다. + * 상태 조회는 로컬 커널과 systemd 정보만 읽으며 omp, 브로커, 모델을 호출하지 않는다. * - * OMP_GATEWAY_TOKEN_FILE regular 0600 file owned by this uid holding the bearer - * OMP_GATEWAY_PORT loopback port (default 43129) + * OMP_GATEWAY_TOKEN_FILE 현재 사용자 소유의 0600 모델 토큰 파일 + * OMP_GATEWAY_MONITOR_TOKEN_FILE 선택 사항: 소유자 전용 0700 디렉터리 내의 별도 0600 토큰 파일 + * OMP_GATEWAY_PORT 루프백 포트(기본값 43129) */ import { spawn } from 'node:child_process'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import { constants, mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { constants, lstat, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { createServer } from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; export const MODELS = ['claude-sonnet-5-5', 'claude-opus-5-5']; @@ -27,6 +24,11 @@ export const MAX_INPUT_BYTES = 1024 * 1024; export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; export const REQUEST_TIMEOUT_MS = 180_000; const PATH = '/v1/messages'; +const HEALTH_PATH = '/healthz'; +const HEALTH_TIMEOUT_MS = 2_000; +const CPU_SAMPLE_MS = 200; +const SYSTEMCTL_OUTPUT_BYTES = 4096; +const UNITS = ['koin-autofix-executor.service', 'homebutler-auth-broker.service']; const TOKEN = /^[A-Za-z0-9_-]{32,512}$/; const ERROR_TYPE = /^[a-z_]{1,64}$/; const MAX_TOKENS = 8192; @@ -78,6 +80,24 @@ export function authorized(header, token) { return timingSafeEqual(a, b); } +/** 모니터 토큰은 파일뿐 아니라 부모 디렉터리도 현재 사용자 소유의 비심볼릭 링크 0700이어야 한다. */ +export async function loadMonitorToken(path) { + if (typeof path !== 'string' || !path) throw new Error('OMP_GATEWAY_MONITOR_TOKEN_FILE is required'); + const parent = await lstat(dirname(path)); + if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error('Monitor token parent must be a directory'); + if ((parent.mode & 0o777) !== 0o700) throw new Error('Monitor token parent mode must be 0700'); + if (typeof process.getuid === 'function' && parent.uid !== process.getuid()) + throw new Error('Monitor token parent must be owned by the current user'); + return loadToken(path); +} + +/** 길이나 접두사에 따른 조기 반환 없이 두 토큰을 비교한다. */ +export function distinctTokens(modelToken, monitorToken) { + const modelHash = createHash('sha256').update(modelToken).digest(); + const monitorHash = createHash('sha256').update(monitorToken).digest(); + return !timingSafeEqual(modelHash, monitorHash); +} + function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -364,6 +384,166 @@ function readBody(req, max, signal) { }); } +function cpuCounters(text) { + const line = text.match(/^cpu\s+([^\n]+)/m)?.[1]; + if (!line) return null; + const fields = line.trim().split(/\s+/); + if (fields.length < 8 || fields.slice(0, 8).some((field) => !/^\d+$/.test(field))) return null; + const counters = fields.slice(0, 8).map(Number); + if (counters.some((value) => !Number.isSafeInteger(value))) return null; + return { counters, total: counters.reduce((sum, value) => sum + value, 0), idle: counters[3] + counters[4] }; +} + +/** Linux /proc/stat 집계값에서 guest 시간은 이미 user/nice에 포함되므로 중복 합산하지 않는다. */ +export function cpuDelta(first, second, sampleStart, sampleEnd) { + if ( + !Number.isSafeInteger(sampleStart) || + sampleStart < 0 || + !Number.isSafeInteger(sampleEnd) || + sampleEnd <= sampleStart || + sampleEnd - sampleStart > 10_000 + ) + return null; + const start = cpuCounters(first); + const end = cpuCounters(second); + if ( + !start || + !end || + !Number.isSafeInteger(start.total) || + !Number.isSafeInteger(end.total) || + end.counters.some((value, index) => value < start.counters[index]) + ) + return null; + const total = end.total - start.total; + const idle = end.idle - start.idle; + if (total <= 0 || idle < 0 || idle > total) return null; + return { usagePercent: ((total - idle) / total) * 100, sampleStart, sampleEnd }; +} + +/** 회수 가능한 캐시를 반영하는 MemAvailable을 사용하며, 읽지 못한 메모리는 null로 둔다. */ +export function memoryReading(text, checkedAt) { + const values = new Map(); + for (const line of text.split('\n')) { + const match = /^(MemTotal|MemAvailable):\s+(\d+)\s+kB\s*$/.exec(line); + if (match) values.set(match[1], Number(match[2]) * 1024); + } + const totalBytes = values.get('MemTotal'); + const availableBytes = values.get('MemAvailable'); + if ( + !Number.isSafeInteger(totalBytes) || + totalBytes <= 0 || + !Number.isSafeInteger(availableBytes) || + availableBytes < 0 || + availableBytes > totalBytes + ) + return null; + return { totalBytes, availableBytes, usedBytes: totalBytes - availableBytes, checkedAt }; +} + +function unknownUnit() { + return { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }; +} + +/** systemctl show의 메타데이터만 읽는다. ActiveState만으로 프로세스 실행을 단정하지 않는다. */ +export function unitReading(text, checkedAt) { + const values = new Map(); + for (const line of text.split('\n')) { + const match = /^(LoadState|ActiveState|SubState|MainPID)=(.*)$/.exec(line); + if (match && !values.has(match[1])) values.set(match[1], match[2]); + } + if (['LoadState', 'ActiveState', 'SubState', 'MainPID'].some((key) => !values.has(key))) return unknownUnit(); + const configured = + values.get('LoadState') === 'loaded' ? true : values.get('LoadState') === 'not-found' ? false : null; + const rawState = values.get('ActiveState'); + const state = ['active', 'inactive', 'failed', 'activating', 'deactivating'].includes(rawState) + ? rawState + : 'unknown'; + const pid = values.get('MainPID'); + const substate = values.get('SubState'); + let processState = 'unknown'; + if (/^\d+$/.test(pid) && Number.isSafeInteger(Number(pid))) { + if (Number(pid) > 0 && substate === 'running') processState = 'running'; + else if (Number(pid) === 0 && ['dead', 'exited', 'failed'].includes(substate)) processState = 'stopped'; + else if (Number(pid) === 0 && state === 'inactive') processState = 'stopped'; + } + return { configured, state, process: processState, checkedAt }; +} + +/** 고정 인수와 출력 상한을 사용하고, 중단 시 프로브를 종료한 뒤 close를 기다린다. */ +function showUnit(unit, { signal }) { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const child = spawn( + 'systemctl', + ['show', '--no-pager', '--property=LoadState,ActiveState,SubState,MainPID', unit], + { + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + signal, + }, + ); + const onAbort = () => child.kill('SIGKILL'); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + let output = ''; + let length = 0; + let failed = false; + child.stdout.on('data', (chunk) => { + length += chunk.length; + if (length > SYSTEMCTL_OUTPUT_BYTES) { + failed = true; + child.kill('SIGKILL'); + } else if (!failed) output += chunk.toString('utf8'); + }); + child.once('error', () => { + failed = true; + }); + child.once('close', (code) => { + signal.removeEventListener('abort', onAbort); + if (failed || code !== 0) reject(new Error('Unit probe unavailable')); + else resolve(output); + }); + }); +} + +/** 각 프로브는 읽기 전용이며 중단 가능하다. 실패한 항목만 독립적으로 미확인 처리한다. */ +export async function collectRockyHealth({ + signal, + now = Date.now, + readProc = readFile, + runSystemctl = showUnit, + sampleMs = CPU_SAMPLE_MS, + platform = process.platform, +} = {}) { + if (!signal) throw new Error('Health collection needs an abort signal'); + signal.throwIfAborted(); + const cpu = async () => { + const sampleStart = now(); + const first = await readProc('/proc/stat', { encoding: 'utf8', signal }); + await sleep(sampleMs, undefined, { signal }); + const second = await readProc('/proc/stat', { encoding: 'utf8', signal }); + return cpuDelta(first, second, sampleStart, now()); + }; + const memory = async () => memoryReading(await readProc('/proc/meminfo', { encoding: 'utf8', signal }), now()); + const unit = async (name) => unitReading(await runSystemctl(name, { signal }), now()); + const results = + platform === 'linux' ? await Promise.allSettled([cpu(), memory(), unit(UNITS[0]), unit(UNITS[1])]) : []; + signal.throwIfAborted(); + const value = (index, fallback) => (results[index]?.status === 'fulfilled' ? results[index].value : fallback); + return { + version: 1, + scope: 'rocky-read-only', + checkedAt: now(), + gateway: 'responding', + cpu: value(0, null), + memory: value(1, null), + executor: value(2, unknownUnit()), + broker: value(3, unknownUnit()), + brokerResponse: 'not_probed', + model: 'not_probed', + }; +} + function send(res, { status, headers, body }) { if (res.destroyed || res.headersSent) return; const bytes = Buffer.from(JSON.stringify(body)); @@ -378,22 +558,31 @@ function send(res, { status, headers, body }) { } /** - * Builds the loopback server. `runRequest` is the stdio runner and `timeoutMs` - * the per-request deadline; tests inject a fake child and a short deadline. - * Logging is limited to path class, status and timing. + * 모델 요청은 기존 단일 자식 프로세스 슬롯을 사용한다. 인증된 상태 조회는 + * 별도의 짧은 제한 시간 내에 실행하며 모델 슬롯을 점유하지 않는다. */ export function createGateway({ token, + monitorToken = null, runRequest = runStdioRequest, + collectHealth = collectRockyHealth, timeoutMs = REQUEST_TIMEOUT_MS, + healthTimeoutMs = HEALTH_TIMEOUT_MS, log = console.error, }) { + if (monitorToken !== null && !distinctTokens(token, monitorToken)) + throw new Error('Monitor and model bearer tokens must differ'); let busy = false; const server = createServer(async (req, res) => { const startedAt = Date.now(); - const route = req.url === PATH; + const modelRoute = req.url === PATH; + const healthRoute = req.url === HEALTH_PATH; const report = (status) => - log('[local-omp-gateway]', { route: route ? PATH : 'other', status, ms: Date.now() - startedAt }); + log('[local-omp-gateway]', { + route: modelRoute ? PATH : healthRoute ? HEALTH_PATH : 'other', + status, + ms: Date.now() - startedAt, + }); // A request whose body was not consumed closes once the answer is flushed, so // the remainder of an oversized or stalled body is dropped rather than drained. const done = (reply) => { @@ -408,7 +597,42 @@ export function createGateway({ req.destroy(); report('disconnected'); }; - if (!route) return done(error(404, 'not_found_error', 'Not found')); + if (!modelRoute && !healthRoute) return done(error(404, 'not_found_error', 'Not found')); + if (healthRoute) { + if (req.method !== 'GET') return done(error(405, 'not_found_error', 'Method not allowed', { Allow: 'GET' })); + if (!monitorToken) return done(error(503, 'api_error', 'Health monitoring unavailable')); + if (!authorized(req.headers.authorization, monitorToken)) + return done(error(401, 'authentication_error', 'Invalid bearer token', { 'WWW-Authenticate': 'Bearer' })); + const controller = new AbortController(); + const deadline = Promise.withResolvers(); + const abort = (reason) => { + controller.abort(reason); + deadline.reject(reason); + }; + const timer = setTimeout(() => abort(new Error('timeout')), healthTimeoutMs); + const onClose = () => { + if (!res.writableFinished) abort(new Error('client disconnected')); + }; + res.on('close', onClose); + try { + // 수집기는 중단 신호를 따르지만, 따르지 않는 구현에도 제한 시간을 강제한다. + const body = await Promise.race([collectHealth({ signal: controller.signal }), deadline.promise]); + if (controller.signal.aborted) { + if (controller.signal.reason?.message === 'timeout') + return done(error(504, 'api_error', 'Health probe timed out')); + return dropped(); + } + return done({ status: 200, body }); + } catch { + if (controller.signal.reason?.message === 'timeout') + return done(error(504, 'api_error', 'Health probe timed out')); + if (controller.signal.aborted) return dropped(); + return done(error(503, 'api_error', 'Health probe unavailable')); + } finally { + clearTimeout(timer); + res.off('close', onClose); + } + } if (req.method !== 'POST') return done(error(405, 'not_found_error', 'Method not allowed', { Allow: 'POST' })); if (!authorized(req.headers.authorization, token)) return done(error(401, 'authentication_error', 'Invalid bearer token', { 'WWW-Authenticate': 'Bearer' })); @@ -463,11 +687,16 @@ export function createGateway({ async function main() { const token = await loadToken(process.env.OMP_GATEWAY_TOKEN_FILE); + const monitorToken = process.env.OMP_GATEWAY_MONITOR_TOKEN_FILE + ? await loadMonitorToken(process.env.OMP_GATEWAY_MONITOR_TOKEN_FILE) + : null; + if (monitorToken !== null && !distinctTokens(token, monitorToken)) + throw new Error('Monitor and model bearer tokens must differ'); const port = process.env.OMP_GATEWAY_PORT === undefined ? DEFAULT_PORT : Number(process.env.OMP_GATEWAY_PORT); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('OMP_GATEWAY_PORT must be a TCP port'); await installOverlay(); await checkReadiness(); - const server = createGateway({ token }); + const server = createGateway({ token, monitorToken }); // Closing live connections aborts the in-flight request, which kills its omp child. const stop = () => { server.close(() => removeOverlay().finally(() => process.exit(0))); diff --git a/scripts/autofix/local-omp-gateway.test.mjs b/scripts/autofix/local-omp-gateway.test.mjs index 836d3bd8..03cc95ab 100644 --- a/scripts/autofix/local-omp-gateway.test.mjs +++ b/scripts/autofix/local-omp-gateway.test.mjs @@ -12,15 +12,22 @@ import { canonicalizeResponse, checkReadiness, childEnv, + collectRockyHealth, + cpuDelta, createGateway, + distinctTokens, installOverlay, + loadMonitorToken, loadToken, + memoryReading, removeOverlay, runStdioRequest, + unitReading, validateRequest, } from './local-omp-gateway.mjs'; const TOKEN = 'a'.repeat(64); +const MONITOR_TOKEN = 'b'.repeat(64); /** A stand-in for `omp auth-gateway stdio` driven by a small inline script. */ function fakeChild(script) { @@ -54,6 +61,26 @@ test('token file must be a 0600 regular non-symlink file with a well-formed bear await assert.rejects(loadToken(''), /required/); }); +test('monitor bearer requires a private owned parent and must differ from the model bearer', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'omp-monitor-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, 'monitor.token'); + await writeFile(file, MONITOR_TOKEN, { mode: 0o600 }); + assert.equal(await loadMonitorToken(file), MONITOR_TOKEN); + assert.equal(distinctTokens(TOKEN, MONITOR_TOKEN), true); + assert.equal(distinctTokens(TOKEN, TOKEN), false); + assert.throws(() => createGateway({ token: TOKEN, monitorToken: TOKEN, log: () => {} })); + await chmod(dir, 0o755); + await assert.rejects(loadMonitorToken(file)); + await chmod(dir, 0o700); + const link = `${dir}-link`; + await symlink(dir, link); + t.after(() => rm(link, { force: true })); + await assert.rejects(loadMonitorToken(join(link, 'monitor.token'))); + await chmod(file, 0o644); + await assert.rejects(loadMonitorToken(file)); +}); + test('the omp child gets a scrubbed environment and only the bridge retry overlay', async (t) => { assert.throws(() => childEnv({ PATH: '/bin' }, null), /overlay/); const file = await installOverlay(); @@ -73,6 +100,7 @@ test('the omp child gets a scrubbed environment and only the bridge retry overla CLAUDE_CODE_USE_FOUNDRY: '1', OPENAI_API_KEY: 'sk-other', OMP_GATEWAY_TOKEN_FILE: '/private/token', + OMP_GATEWAY_MONITOR_TOKEN_FILE: '/private/monitor', }); assert.deepEqual(env, { PI_CONFIG_FILES: file, @@ -336,3 +364,251 @@ test('a stalled or dropped request body never pins the single slot, and oversize assert.match(await complete(), /^HTTP\/1\.1 200 /); assert.equal(spawns, 3); }); + +test('Linux counters use a real two-sample busy delta and MemAvailable arithmetic', async () => { + const first = 'cpu 100 10 20 70 10 5 5 0 9 3\n'; + const second = 'cpu 120 10 30 90 10 5 5 0 99 3\n'; + assert.deepEqual(cpuDelta(first, second, 1000, 1200), { + usagePercent: 60, + sampleStart: 1000, + sampleEnd: 1200, + }); + assert.deepEqual(memoryReading('MemTotal: 1000 kB\nMemAvailable: 400 kB\n', 1200), { + totalBytes: 1024000, + availableBytes: 409600, + usedBytes: 614400, + checkedAt: 1200, + }); + const calls = []; + let samples = 0; + let clock = 1700000000000; + const result = await collectRockyHealth({ + signal: new AbortController().signal, + now: () => clock, + platform: 'linux', + sampleMs: 0, + readProc: async (path) => { + calls.push(path); + if (path === '/proc/meminfo') return 'MemTotal: 1000 kB\nMemAvailable: 400 kB\n'; + samples += 1; + if (samples === 2) clock += 200; + return samples === 1 ? first : second; + }, + runSystemctl: async (name) => { + calls.push(name); + return name === 'koin-autofix-executor.service' + ? 'LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=123\n' + : 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\n'; + }, + }); + assert.deepEqual( + calls.sort(), + [ + '/proc/meminfo', + '/proc/stat', + '/proc/stat', + 'homebutler-auth-broker.service', + 'koin-autofix-executor.service', + ].sort(), + ); + assert.deepEqual(result, { + version: 1, + scope: 'rocky-read-only', + checkedAt: 1700000000200, + gateway: 'responding', + cpu: { usagePercent: 60, sampleStart: 1700000000000, sampleEnd: 1700000000200 }, + memory: { totalBytes: 1024000, availableBytes: 409600, usedBytes: 614400, checkedAt: 1700000000000 }, + executor: { configured: true, state: 'active', process: 'running', checkedAt: 1700000000000 }, + broker: { configured: false, state: 'inactive', process: 'stopped', checkedAt: 1700000000000 }, + brokerResponse: 'not_probed', + model: 'not_probed', + }); +}); + +test('counter resets, malformed memory and failed systemd reads stay unknown, not zero', async () => { + assert.equal(cpuDelta('cpu 10 0 0 10 0 0 0 0\n', 'cpu 9 0 0 20 0 0 0 0\n', 1, 2), null); + assert.equal(cpuDelta('cpu 10 0 0 10 0 0 0 0\n', 'cpu 10 0 0 10 0 0 0 0\n', 1, 2), null); + const before = 'cpu 10 0 0 10 0 0 0 0\n'; + const after = 'cpu 20 0 0 20 0 0 0 0\n'; + assert.equal(cpuDelta(before, after, 1000, 1000), null); + assert.equal(cpuDelta(before, after, 1001, 1000), null); + assert.equal(cpuDelta(before, after, 1000, 11001), null); + assert.equal(cpuDelta(before, after, Number.NaN, 1100), null); + assert.equal(memoryReading('MemTotal: 100 kB\nMemAvailable: 101 kB\n', 2), null); + assert.deepEqual(unitReading('LoadState=loaded\nActiveState=active\nSubState=exited\nMainPID=0\n', 2), { + configured: true, + state: 'active', + process: 'stopped', + checkedAt: 2, + }); + assert.deepEqual(unitReading('LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=0\n', 2), { + configured: true, + state: 'active', + process: 'unknown', + checkedAt: 2, + }); + const result = await collectRockyHealth({ + signal: new AbortController().signal, + platform: 'linux', + sampleMs: 0, + now: () => 1234, + readProc: async () => { + throw new Error('/private/raw-path'); + }, + runSystemctl: async () => { + throw new Error('sensitive systemctl output'); + }, + }); + assert.equal(result.cpu, null); + assert.equal(result.memory, null); + assert.deepEqual(result.executor, { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }); + assert.deepEqual(result.broker, result.executor); + assert.doesNotMatch(JSON.stringify(result), /private|sensitive|MainPID/); +}); + +test( + 'health route is exact, role-isolated, and remains responsive while the model slot is busy', + { timeout: 5000 }, + async (t) => { + let modelCalls = 0; + let healthCalls = 0; + const modelStarted = Promise.withResolvers(); + const modelRelease = Promise.withResolvers(); + const health = { + version: 1, + scope: 'rocky-read-only', + checkedAt: 1234, + gateway: 'responding', + cpu: null, + memory: null, + executor: { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }, + broker: { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }, + brokerResponse: 'not_probed', + model: 'not_probed', + }; + const server = createGateway({ + token: TOKEN, + monitorToken: MONITOR_TOKEN, + log: () => {}, + collectHealth: async () => { + healthCalls += 1; + return health; + }, + runRequest: ({ body }) => { + modelCalls += 1; + modelStarted.resolve(); + return modelRelease.promise.then(() => ({ + status: 200, + body: { type: 'message', model: body.model, content: [], stop_reason: 'end_turn' }, + })); + }, + }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => { + modelRelease.resolve(); + server.closeAllConnections(); + server.close(); + }); + const origin = `http://127.0.0.1:${server.address().port}`; + const modelHeaders = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }; + const monitorHeaders = { Authorization: `Bearer ${MONITOR_TOKEN}` }; + const modelBody = JSON.stringify({ + model: 'claude-sonnet-5-5', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + }); + const modelPost = (headers) => fetch(`${origin}/v1/messages`, { method: 'POST', headers, body: modelBody }); + assert.equal((await fetch(`${origin}/healthz`)).status, 401); + assert.equal((await fetch(`${origin}/healthz`, { headers: modelHeaders })).status, 401); + assert.equal((await modelPost(monitorHeaders)).status, 401); + for (const path of ['/healthz?x=1', '/healthz/', '/HEALTHZ', '/unknown']) { + assert.equal((await fetch(`${origin}${path}`, { headers: monitorHeaders })).status, 404); + } + const method = await fetch(`${origin}/healthz`, { method: 'POST', headers: monitorHeaders }); + assert.equal(method.status, 405); + assert.equal(method.headers.get('allow'), 'GET'); + assert.equal(healthCalls, 0); + assert.equal(modelCalls, 0); + const pending = modelPost(modelHeaders); + await modelStarted.promise; + const reply = await fetch(`${origin}/healthz`, { headers: monitorHeaders }); + assert.equal(reply.status, 200); + assert.deepEqual(await reply.json(), health); + assert.equal(healthCalls, 1); + assert.equal(modelCalls, 1); + modelRelease.resolve(); + assert.equal((await pending).status, 200); + }, +); + +test('unconfigured health returns 503 without changing model credential behavior', async (t) => { + const server = createGateway({ token: TOKEN, log: () => {} }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => server.close()); + const origin = `http://127.0.0.1:${server.address().port}`; + assert.equal((await fetch(`${origin}/healthz`, { headers: { Authorization: `Bearer ${TOKEN}` } })).status, 503); + assert.equal((await fetch(`${origin}/healthz`, { method: 'POST' })).headers.get('allow'), 'GET'); +}); + +test('health collector deadline aborts its work and frees the response', async (t) => { + let aborted = false; + const server = createGateway({ + token: TOKEN, + monitorToken: MONITOR_TOKEN, + healthTimeoutMs: 30, + log: () => {}, + collectHealth: ({ signal }) => + new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + aborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => server.close()); + const started = Date.now(); + const response = await fetch(`http://127.0.0.1:${server.address().port}/healthz`, { + headers: { Authorization: `Bearer ${MONITOR_TOKEN}` }, + }); + assert.equal(response.status, 504); + assert.ok(Date.now() - started < 1000); + assert.equal(aborted, true); +}); + +test('health deadline returns 504 even if an injected collector ignores abort', { timeout: 5000 }, async (t) => { + let aborted = false; + const server = createGateway({ + token: TOKEN, + monitorToken: MONITOR_TOKEN, + healthTimeoutMs: 30, + log: () => {}, + collectHealth: ({ signal }) => + new Promise(() => { + signal.addEventListener( + 'abort', + () => { + aborted = true; + }, + { once: true }, + ); + }), + }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => server.close()); + const started = Date.now(); + const response = await fetch(`http://127.0.0.1:${server.address().port}/healthz`, { + headers: { Authorization: `Bearer ${MONITOR_TOKEN}` }, + }); + assert.equal(response.status, 504); + assert.ok(Date.now() - started < 1000); + assert.equal(aborted, true); + assert.deepEqual(await response.json(), { + type: 'error', + error: { type: 'api_error', message: 'Health probe timed out' }, + }); +});