o2o-infinith-demo/vite.config.ts
Haewon Kam e20a90dc35 fix: 서버 전용 키가 브라우저 번들에 들어가지 않게 한다
- vite.config define 의 GEMINI_API_KEY 를 지운다
- API 대시보드의 import.meta.env 동적 조회를 공개 변수 목록으로 바꾼다. 동적 조회 때문에 VITE_ 변수 전체(VITE_CREATOMATE_API_KEY 포함)가 번들에 들어갔다
- 모든 배포 빌드 끝에 서버 전용 키 값이 번들에 있으면 빌드를 실패시킨다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:39:19 +09:00

137 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
import { supabaseSync } from './plugins/vite-plugin-supabase-sync';
import { assertNoSecretValues, assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
// 배포별 head 메타. VITE_SITE=discovery 로 빌드한 배포(webforai.kr)는 AI Discovery 랜딩이 루트라
// 제목·설명도 그에 맞아야 한다. index.html 은 두 배포가 공용이므로 빌드 때 갈아끼운다.
// 답변엔진이 읽는 첫 신호라 비워 두지 않는다.
const isDiscovery = env.VITE_SITE === 'discovery';
const head = isDiscovery
? {
title: 'AI Discovery · 우리 병원은 AI 답변에 나오고 있습니까',
description: '병원 홈페이지 주소 하나로 AI 답변 준비도를 진단하고, 무엇을 고쳐야 하는지 근거와 함께 알려 드립니다.',
}
: {
title: 'INFINITH - Infinite Marketing',
description: 'AI 마케팅 분석 플랫폼. 채널을 진단하고 전략과 로드맵을 제안합니다.',
};
// 검색엔진 소유 확인. 배포마다 값이 다르므로 여기서 갈아끼운다.
// 네이버 서치어드바이저는 DNS 를 받지 않아 메타 태그로만 확인된다.
// 구글은 webforai.kr 도메인 속성을 DNS TXT 로 확인했으므로 메타가 필요 없다.
const verify = isDiscovery
? ['<meta name="naver-site-verification" content="6ebcb18640acf31bb3653822d178fcf071b94009" />']
: [];
// 링크 공유 미리보기 이미지와 파비콘(scripts/make_share_assets.py discovery 로 생성).
// og:image 는 절대 주소여야 카카오톡·슬랙이 읽는다. public/ 은 infinith-demo 와 공용이라 discovery 빌드에만 넣는다.
const share = isDiscovery
? [
'<meta property="og:url" content="https://webforai.kr" />',
'<meta property="og:image" content="https://webforai.kr/og-discovery.jpg" />',
'<meta property="og:image:width" content="1200" />',
'<meta property="og:image:height" content="630" />',
'<meta name="twitter:card" content="summary_large_image" />',
'<link rel="icon" href="/discovery-favicon.ico" sizes="48x48" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/discovery-favicon-32.png" />',
'<link rel="icon" type="image/png" sizes="192x192" href="/discovery-favicon-192.png" />',
'<link rel="apple-touch-icon" href="/discovery-apple-touch-icon.png" />',
]
: [];
// 방문 측정: Google 태그 관리자(GTM) 컨테이너. GA4(G-013Z3BFY35)와 키 이벤트는 컨테이너 안에서 설정한다.
// 공개 배포(webforai.kr)에만 넣는다. 컨테이너 ID 는 공개 값이다. 개인정보처리방침 제5·8조와 함께 관리한다.
const GTM_ID = 'GTM-WQNTZVZH';
const gtmHead = isDiscovery
? [`<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${GTM_ID}');</script>`]
: [];
const gtmBody = isDiscovery
? `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`
: '';
const headHtml = [
`<title>${head.title}</title>`,
`<meta name="description" content="${head.description}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:title" content="${head.title}" />`,
`<meta property="og:description" content="${head.description}" />`,
`<meta property="og:locale" content="ko_KR" />`,
...share,
...verify,
...gtmHead,
].join('\n ');
return {
plugins: [
react(),
{
// 루트 /favicon.ico. 미리보기 카드와 일부 브라우저는 <link rel="icon"> 을 보지 않고 루트를 그대로 요청한다.
// vercel.json 의 SPA 리라이트가 모든 경로를 index.html 로 보내므로, 파일이 없으면 HTML 이 아이콘 자리에 온다.
// public/ 은 infinith-demo 배포와 공용이라 파일명을 나눠 두었고, 빌드 때 이 배포의 것만 루트 이름으로 복사한다.
name: 'infinith-root-favicon',
apply: 'build' as const,
writeBundle(options: {dir?: string}) {
const out = options.dir ?? 'dist';
// 모든 배포 공통: 서버 전용 키 값이 번들에 있으면 빌드를 멈춘다.
assertNoSecretValues(out, env);
if (!isDiscovery) return;
// public/ 은 infinith-demo 와 공용이라 실제 병원 리포트 원본(reports/)과 병원 채널 스크린샷(assets/clients/)이 들어 있다.
// 공개 배포(webforai.kr)에서는 링크가 없어도 주소만 알면 열리므로 빌드 결과에서 지운다 (2026-10-07).
for (const dir of ['reports', path.join('assets', 'clients')]) {
fs.rmSync(path.resolve(out, dir), { recursive: true, force: true });
}
for (const [from, to] of [
['discovery-favicon.ico', 'favicon.ico'],
['discovery-favicon-32.png', 'favicon-32.png'],
['discovery-favicon-192.png', 'favicon-192.png'],
['discovery-apple-touch-icon.png', 'apple-touch-icon.png'],
]) {
const src = path.resolve(out, from);
if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to));
}
// 정리가 끝난 결과물을 검사한다. 실제 병원명·비밀 키가 남아 있으면 빌드를 실패시켜 배포를 막는다.
// writeBundle 은 플러그인끼리 병렬로 돌 수 있어 별도 플러그인이 아니라 여기서 정리 뒤에 부른다.
assertPublicBundleClean(out);
},
},
{
name: 'infinith-head',
transformIndexHtml(html: string) {
return html
.replace(/<title>[\s\S]*?<\/title>/, headHtml)
.replace('<body>', gtmBody ? `<body>\n ${gtmBody}` : '<body>');
},
},
tailwindcss(),
supabaseSync({
supabaseUrl: env.VITE_SUPABASE_URL ?? '',
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '',
}),
],
// 서버 전용 키(Gemini·Creatomate 등)는 define·VITE_ 접두사로 번들에 넣지 않는다. 호출은 api/*.js 서버 함수에서 한다 (2026-10-07).
build: {
rollupOptions: {
treeshake: {
// src/data(목업·진단 데이터)와 src/services 는 import 만으로 할 일이 없는 모듈이다. 쓰는 곳이 없으면 번들에서 뺀다.
// 파생 목업의 최상단 객체 펼치기를 부수효과로 보고 실제 병원 목업이 공개 번들에 남던 문제를 막는다.
// src/lib/supabase 도 같다. 최상단 createClient() 때문에 공개 배포에 Supabase 접속 정보가 남았다 (2026-10-07).
moduleSideEffects: (id: string) =>
!/[\\/]src[\\/](data|services)[\\/]/.test(id) && !/[\\/]src[\\/]lib[\\/]supabase\.ts$/.test(id),
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});