테마
빌드와 패키징
BP 저장소가 만들어 내야 하는 것은 디렉터리 하나다. 포탈은 그것을 설치 위치에 놓기만 하면 부팅 시 알아서 찾는다.
1. 산출물 배치
dist/ ← 이 디렉터리 전체가 배포 단위다
bp-manifest.yaml
backend/index.mjs ← register()를 export하는 ESM 번들
backend/prisma/sqlite/{schema.prisma, migrations/}
backend/prisma/postgresql/{schema.prisma, migrations/}
frontend/bundle.js ← csr-widget 번들(화면이 있는 BP만)경로는 bp-manifest.yaml의 backend.entry / backend.database.schemaDir / frontend.bundle이 가리키는 값과 일치해야 한다.
2. 백엔드 번들
번들러는 esbuild, 트랜스파일은 TypeScript
esbuild는 emitDecoratorMetadata를 지원하지 않는다(타입 정보가 필요하므로). 그런데 NestJS의 DI가 생성자 타입을 design:paramtypes 메타데이터로 읽는다. 빠뜨리면 컴파일은 통과하고 런타임에 주입이 깨진다.
그래서 .ts는 ts.transpileModule로 먼저 변환하고, 번들링만 esbuild에 맡긴다.
js
import * as esbuild from "esbuild";
import ts from "typescript";
import { readFile } from "node:fs/promises";
import { HOST_PROVIDED_MODULES } from "@starj-portal/bp-backend-sdk";
const tsLoader = {
name: "ts-transpile",
setup(build) {
// TypeScript(NodeNext)는 상대 import를 "./foo.js"로 쓰지만 실제 파일은 foo.ts다.
// esbuild는 이 치환을 하지 않으므로 직접 해석해 준다.
build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => ({
path: new URL(args.path.replace(/\.js$/, ".ts"), `file://${args.resolveDir}/`).pathname,
}));
build.onLoad({ filter: /\.ts$/ }, async (args) => {
const source = await readFile(args.path, "utf8");
const out = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2023,
module: ts.ModuleKind.ESNext,
experimentalDecorators: true,
emitDecoratorMetadata: true, // ← 이 줄이 핵심
},
});
return { contents: out.outputText, loader: "js" };
});
},
};
await esbuild.build({
entryPoints: ["backend/src/register.ts"],
outfile: "dist/backend/index.mjs",
bundle: true,
format: "esm",
platform: "node",
target: "node22",
external: [...HOST_PROVIDED_MODULES], // ← 직접 적지 말 것
plugins: [tsLoader],
});externals와 버전 고정
external에 넣은 모듈은 번들에 안 들어가고 포탈 것이 쓰인다. 그래서 그 모듈들은 HOST_PROVIDED_VERSIONS의 값으로 캐럿 없이 고정해야 한다. 자세한 이유는 host-contract.md §3.
jsonc
"dependencies": { "@prisma/client": "7.8.0", "@nestjs/common": "11.1.28" }CI에서 이 정합을 검사해 두면 사고를 미리 막을 수 있다.
js
import { HOST_PROVIDED_VERSIONS } from "@starj-portal/bp-backend-sdk";
const deps = JSON.parse(await readFile("package.json", "utf8")).dependencies ?? {};
for (const [name, expected] of Object.entries(HOST_PROVIDED_VERSIONS)) {
if (deps[name] && deps[name] !== expected) {
throw new Error(`${name}: ${deps[name]} — 포탈은 ${expected}을 제공한다(캐럿 금지)`);
}
}런타임 자산 경로 — 번들에서 어긋나는 지점
번들은 모든 소스를 한 파일로 합치므로 import.meta.dirname이 파일별 위치가 아니라 번들 위치 하나가 된다. 파일마다 자기 깊이로 상대경로를 잡아 두면 번들에서 전부 어긋나고, 결과는 부팅 시 ENOENT → 그 BP만 조용히 스킵이다.
경로 계산을 한 모듈에 모으고, 개발(backend/src → backend/{seed,assets})과 번들(dist/backend → dist/{seed,assets}) 양쪽에 같은 규칙("이 모듈 기준 ..")이 맞도록 배치한다.
3. 방언별 Prisma 산출물
방언마다 쿼리 컴파일러가 다르므로 클라이언트 한 벌로 양쪽을 커버할 수 없다.
bash
# 정본 schema.prisma에서 방언별 스키마를 파생시키고(datasource provider와 output 경로 치환)
# 각각 generate 한다
prisma generate --schema=prisma/sqlite/schema.prisma
prisma generate --schema=prisma/postgresql/schema.prisma스키마를 두 벌 손으로 관리하면 반드시 갈라진다 — 정본 하나에서 파생시키는 스크립트를 둔다.
4. 프론트엔드 번들
자기 React를 포함하는 독립 IIFE로 만든다. 셸과 React를 공유하지 않으므로 externals가 없다.
js
await esbuild.build({
entryPoints: ["frontend/src/index.tsx"],
outfile: "dist/frontend/bundle.js",
bundle: true,
format: "iife", // ← ESM이 아니다. <script> 태그로 로드된다
target: "es2022",
minify: true,
loader: { ".css": "text" }, // CSS를 문자열로 받아 shadow root에 심는다
});format: "esm"으로 만들면 window.__BP_WIDGETS__ 등록이 실행되지 않아 셸이 위젯을 찾지 못한다.
5. 배포 전 자체 점검
포탈에 올리기 전에 확인할 것들이다. 대부분의 실패가 침묵으로 나타나므로, 이 점검이 사실상 유일한 조기 경보다.
| 확인 | 명령 / 방법 |
|---|---|
번들이 register를 export하나 | node -e "import('./dist/backend/index.mjs').then(m => console.log(typeof m.register))" → function |
| 호스트 제공 모듈이 번들에 섞여 들어갔나 | grep -c "@nestjs/common" dist/backend/index.mjs → 0에 가까워야 한다(import 문만 남아야 함) |
| 매니페스트가 스키마에 맞나 | BpInstallManifest 타입으로 생성하면 컴파일이 검사한다 |
| 매니페스트 경로가 실재하나 | entry/schemaDir/bundle이 가리키는 파일을 ls로 확인 |
| 위젯이 전역에 등록되나 | 번들을 브라우저에서 로드한 뒤 window.__BP_WIDGETS__ 확인 |
| 마이그레이션 폴더 위치 | schema.prisma와 migrations/가 같은 폴더인지 |
함께 볼 것
- install-to-portal.md — 만든 디렉터리를 포탈에 올리는 법
- troubleshooting.md — 올렸는데 아무 일도 안 일어날 때