"파일 하나짜리면 순수 JS 로도 충분해. npm 패키지랑 TypeScript를 쓰면서 파일이 여러 개가 되는 순간부터 bundler가 필요해지고. 이 lesson은 src/를 1 초 만에 dist/로 바꿔 주는 최소한의 esbuild 설정이야. sourcemap, 저장하면 다시 빌드, Web Store에 그대로 올릴 수 있는 결과물까지."
왜 bundler
파일 하나가 200 줄쯤까지라면 content.js / background.js / popup.js / panel.js를 그냥 흩어 놓는 순수 JS 로도 충분해. bundler가 밥값을 하기 시작하는 건 이럴 때야:
- 타입을 쓰려고 TypeScript로 가고 싶을 때.
- 내부에서 ES module을 import 하는 npm 패키지를 갖다 쓸 때 (파일 한 장으로 끝나지 않는 npm 패키지는 거의 다 여기 해당돼).
- 여러 진입점이 같이 쓰는 helper가 생겼을 때 (popup도 panel도 content도 같은 clip 한 줄 렌더러가 필요하다든지).
- source는 src/에, 배포본은 dist/에 두고, 빌드하면서 manifest 랑 HTML을 옮기거나 손보고 싶을 때.
Default 로서 esbuild
esbuild는 JS 든 TS 든 밀리초 단위로 컴파일해. 진입점 몇 개짜리 TypeScript extension 이라면 빌드 전체가 이 한 덩어리로 끝나:
esbuild \
src/background.ts \
src/content.ts \
src/popup.ts \
src/panel.ts \
--bundle \
--outdir=dist \
--target=chrome120 \
--sourcemap=inline \
--format=iife
진입점 넷, 명령 하나, 그리고 다 묶인 결과 파일 넷 (background.js, content.js, popup.js, panel.js). 결과물은 IIFE 형태로 나오는데, 이게 Chrome이 content script와 예전 방식 background 페이지에서 원하는 모양이야. --target=chrome120은 어떤 JS 기능까지는 굳이 옛날 문법으로 낮추지 말라고 esbuild 한테 알려 주는 거고.
Layout
clipdeck/
src/
background.ts
content.ts
popup.ts
panel.ts
shared/
clip-row.ts
escape-html.ts
types.ts
static/
manifest.json
popup.html
panel.html
icons/
16.png 32.png 48.png 128.png
vendor/
Readability.js
scripts/
build.mjs
dist/ # gitignore, 생성됨
package.json
tsconfig.json
빌드 script는 static/을 dist/로 복사하고, src/를 묶어서 dist/에 떨궈. npm run build 한 번이면 Chrome이 바로 올릴 수 있는 dist/가 생기는 거지.
빌드 script
esbuild의 Node API를 쓰면 node scripts/build.mjs로 돌릴 파일 한 장이면 돼:
import { build } from 'esbuild';
import { copyFile, mkdir, cp } from 'fs/promises';
await mkdir('dist', { recursive: true });
await cp('static', 'dist', { recursive: true });
await build({
entryPoints: ['src/background.ts', 'src/content.ts', 'src/popup.ts', 'src/panel.ts'],
bundle: true,
outdir: 'dist',
target: 'chrome120',
format: 'iife',
sourcemap: 'inline',
logLevel: 'info',
});
개발 중에 저장할 때마다 다시 빌드하고 싶으면 --watch와 esbuild의 context API를 붙여. 여기에 extension을 다시 올려 주는 도구 (extension-reloader 나, 개발용 SW에서 chrome.runtime.reload를 부르는 작은 코드)를 짝지으면 파일 저장이 곧 Chrome의 reload가 돼.
TypeScript 설정
extension에 쓸 tsconfig.json은 이래:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["chrome"],
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
chrome.* API 타입을 받으려면 @types/chrome을 깔아. noEmit: true를 준 이유는 실제 컴파일은 esbuild가 하고 tsc는 타입만 봐 주면 되기 때문이야. tsc --noEmit을 돌리는 npm run typecheck script를 따로 두고 별도로 확인해.
package.json script
{
"scripts": {
"build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch",
"typecheck": "tsc --noEmit",
"package": "npm run typecheck && npm run build && cd dist && zip -r ../clipdeck.zip ."
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.5.0",
"@types/chrome": "^0.0.300"
}
}
npm run package 하나면 타입 검사, 빌드, zip이 한 번에 끝나. 그 zip이 Web Store에 올리는 물건이고. npm run watch는 개발할 때 도는 고리야. chrome://extensions를 자동으로 다시 올려 주는 것 (전용 extension 이든, chrome.runtime.reload를 듣는 작은 SW listener 든)과 짝지어 쓰면 돼.
넣어 둔 파일에서 패키지로 옮기기
Track 7 에서는 Readability.js를 파일째로 넣어 뒀지. bundler가 생겼으니 npm install @mozilla/readability 하고 평범하게 import 하면 돼:
// src/content.ts
import { Readability } from '@mozilla/readability';
// ...이전과 정확히 같이 사용
묶인 content.js 안에 Readability 코드가 그대로 들어가. 더 깔끔하고, 버전이 관리되고, 가능한 데선 안 쓰는 코드도 털려 나가. 그러면 static/vendor/ 폴더는 쓸모가 없어지니까 빌드 결과에서 빼 버려.
아래 build script는 static asset을 copy하기 전에 dist/를 지워서, 삭제한 옛 파일이 다음 package에 새어 들어가지 않게 해. --watch branch는 esbuild의 context().watch(), 일반 branch는 build()를 써. Script와 설명을 같은 계약으로 유지해.