@ktds-axbd/harness-kit
v0.22.0
Published
AX BD Cloudflare Workers MSA scaffold + harness CLI (auth/CORS/D1/event-bus/middleware)
Maintainers
Readme
@ktds-axbd/harness-kit
AX BD MSA 서비스 그룹의 공통 기반 패키지. 새 Cloudflare Workers 서비스를 1분 내 생성하고, 인증/CORS/이벤트/ESLint를 일관되게 설정해요.
Quick Start
1. 새 서비스 생성
npx harness create gate-x --service-id gate-x --account-id <CF_ACCOUNT_ID>
cd gate-x
pnpm install2. app.ts에 harness-kit 통합
import { Hono } from "hono";
import {
createAuthMiddleware,
createCorsMiddleware,
errorHandler,
rbac,
} from "@ktds-axbd/harness-kit";
const config = {
serviceName: "gate-x",
serviceId: "gate-x" as const,
corsOrigins: ["https://fx.minu.best"],
publicPaths: ["/api/auth/", "/api/openapi.json"],
};
const app = new Hono<{ Bindings: Env }>();
app.use("*", createCorsMiddleware(config));
app.use("*", createAuthMiddleware(config));
app.use("*", errorHandler());
// 관리자 전용 라우트
app.get("/api/admin", rbac("admin"), (c) => c.json({ ok: true }));
export default app;3. 실행
pnpm dev # wrangler dev (로컬)
pnpm test # vitestCLI
harness create <name>
새 서비스 scaffold를 생성해요.
harness create <name> [options]
Options:
--service-id <id> 서비스 ID (기본: foundry-x)
--account-id <id> Cloudflare Account ID
--db-name <name> D1 Database 이름 (기본: <name>-db)
-o, --output <dir> 출력 디렉토리 (기본: ./<name>)유효한 service-id:
| ID | 서비스 |
|----|--------|
| foundry-x | Foundry-X (발굴+형상화) |
| gate-x | Gate-X (검증) |
| launch-x | Launch-X (제품화+GTM) |
| eval-x | Eval-X (평가) |
| discovery-x | Discovery-X (수집) |
| ai-foundry | AI Foundry (AI 에이전트) |
생성 파일:
<name>/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── wrangler.toml
└── src/
├── index.ts # Workers entry
├── app.ts # Hono app + harness-kit 설정
└── env.ts # 환경 타입 정의API Reference
Middleware
createAuthMiddleware(config)
JWT 검증 미들웨어. publicPaths에 등록된 경로는 건너뛰어요.
import { createAuthMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createAuthMiddleware({
serviceName: "gate-x",
serviceId: "gate-x",
corsOrigins: ["https://fx.minu.best"],
publicPaths: ["/api/auth/", "/api/openapi.json"],
jwtAlgorithm: "HS256", // 기본값
}));JWT Payload 타입:
interface JwtPayload {
sub: string;
email: string;
role: "admin" | "member" | "viewer";
orgId?: string;
orgRole?: "owner" | "admin" | "member" | "viewer";
services?: Array<{ id: string; role: string }>;
iat: number;
exp: number;
}createCorsMiddleware(config)
CORS 미들웨어. corsOrigins 기반으로 허용 Origin을 설정해요.
app.use("*", createCorsMiddleware(config));허용 메서드: GET, POST, PUT, PATCH, DELETE, OPTIONS
rbac(minRole)
역할 기반 접근 제어. JWT payload의 role을 확인해요.
type Role = "admin" | "member" | "viewer";
// admin만 접근 가능
app.delete("/api/resource/:id", rbac("admin"), handler);
// member 이상 접근 가능
app.post("/api/resource", rbac("member"), handler);역할 레벨: admin(3) > member(2) > viewer(1)
errorHandler()
표준 에러 응답 포맷을 처리해요.
app.use("*", errorHandler());
// HarnessError 직접 사용
import { HarnessError } from "@ktds-axbd/harness-kit";
throw new HarnessError("NOT_FOUND", "Resource not found", 404);createStranglerMiddleware(config)
Strangler Fig 패턴 라우터. 서비스 이관 전/후 트래픽을 분기해요.
import { createStranglerMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createStranglerMiddleware({
routes: [
{
pathPrefix: "/api/gate",
serviceId: "gate-x",
mode: "local", // 이관 전: 모놀리스 처리
},
{
pathPrefix: "/api/launch",
serviceId: "launch-x",
mode: "proxy", // 이관 후: 외부 서비스 포워딩
targetUrl: "https://launch-x.ktds-axbd.workers.dev",
},
],
}));createMultiTenantMiddleware(config?) — v0.2.0
JWT payload의 orgId를 검증하고 컨텍스트에 테넌트 정보를 주입해요.
import { createMultiTenantMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createMultiTenantMiddleware({
bypassPaths: ["/health", "/api/public"],
onMissingOrgId: (c) => c.json({ error: "Org required" }, 403),
}));
// 이후 핸들러에서
app.get("/api/data", (c) => {
const orgId = c.get("orgId"); // string
const orgRole = c.get("orgRole"); // string (default: "member")
const userId = c.get("userId"); // string (= payload.sub)
return c.json({ orgId, orgRole, userId });
});Config options (v0.2.0): bypassPaths?, onMissingOrgId?
Config options (v0.3.0 추가): d1Binding?, d1Table? (default: "org_members"), d1Mode? ("optional" | "forced", default: "optional"), onMembershipDenied?
// v0.3.0: D1 membership 강제 검증
app.use("*", createMultiTenantMiddleware({
d1Binding: "DB", // Cloudflare D1 binding key
d1Mode: "forced", // D1 없으면 503, row 없으면 403
d1Table: "org_members", // 기본값, 커스터마이즈 가능
onMembershipDenied: (c, reason) => c.json({ error: "Access denied", reason }, 403),
}));D1 권장 schema:
CREATE TABLE IF NOT EXISTS org_members (
user_id TEXT NOT NULL,
org_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
PRIMARY KEY (user_id, org_id)
);d1Mode 선택 가이드:
"optional"(기본): D1 없으면 JWT-only fallback (v0.2.0 동작과 동일)"forced": D1 binding 필수, 없으면 503. 운영 보안 강화 시 사용
createCacheMiddleware(config) — v0.3.0
Cache-Control 헤더를 응답에 설정해요. CDN/브라우저 캐싱 정책을 미들웨어로 중앙 관리할 수 있어요.
import { createCacheMiddleware } from "@ktds-axbd/harness-kit";
// GET/HEAD 요청 5분 캐시
app.use("/api/static/*", createCacheMiddleware({
maxAge: 300, // browser cache 5분
sMaxAge: 3600, // CDN cache 1시간
staleWhileRevalidate: 60, // 재검증 중 stale 60초 허용
scope: "public", // "public" | "private" (default: "public")
methods: ["GET", "HEAD"], // (default)
}));
// 개인화 데이터: private 캐시
app.use("/api/profile", createCacheMiddleware({
maxAge: 60,
scope: "private",
}));Config options: maxAge?, sMaxAge?, staleWhileRevalidate?, scope?, pathMatch?, methods?
동작 규칙:
- 4xx/5xx 응답 → Cache-Control 헤더 미설정 (안전)
pathMatch미일치 또는methods미일치 → 건너뜀
createCompressionMiddleware(config?) — v0.3.0
gzip/deflate 압축을 적용해요. hono/compress를 래핑하며 skipPaths로 스트리밍 경로를 제외할 수 있어요.
import { createCompressionMiddleware } from "@ktds-axbd/harness-kit";
// 전역 gzip 압축 (streaming 경로 제외)
app.use("*", createCompressionMiddleware({
skipPaths: ["/api/stream", "/api/sse"], // 건너뜀
threshold: 1024, // 최소 압축 크기 bytes (기본: 1024)
encoding: "gzip", // "gzip" | "deflate" (기본: Accept-Encoding 자동)
}));Config options: threshold?, encoding?, skipPaths?
참고: Cloudflare Workers는 자동 gzip/brotli를 지원하지만, 이 미들웨어를 사용하면 명시적 제어(skipPaths, threshold)가 가능해요.
createRateLimitMiddleware(config) — v0.2.0
요청 수를 제한해요. KV namespace가 없으면 in-memory fallback을 사용해요.
import { createRateLimitMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createRateLimitMiddleware({
limit: 100, // 최대 요청 수
windowSec: 60, // 윈도우 (초)
keyFn: (c) => c.req.header("CF-Connecting-IP") ?? "anon",
kvBinding: "RATE_LIMIT_KV", // optional: 분산 카운터
}));Config options: limit (required), windowSec (required), keyFn?, kvBinding?
createRequestLoggerMiddleware(config?) — v0.2.0
요청/응답을 구조화된 JSON 또는 텍스트로 로깅해요. 민감 헤더는 자동 redact돼요.
import { createRequestLoggerMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createRequestLoggerMiddleware({
format: "json", // "json" | "text"
logger: console.log,
redactHeaders: ["authorization", "cookie", "x-api-key"],
}));
// 출력: {"method":"GET","path":"/api","status":200,"duration":12,"traceId":"...","timestamp":"..."}Config options: format?, logger?, redactHeaders?
createTraceIdMiddleware(config?) — v0.2.0
요청 ID를 전파해요. 인입 헤더가 있으면 재사용하고, 없으면 UUID를 생성해요.
import { createTraceIdMiddleware } from "@ktds-axbd/harness-kit";
// 권장 ordering: traceId → logger → auth → tenant
app.use("*", createTraceIdMiddleware({ header: "x-trace-id" }));
app.get("/api", (c) => {
const traceId = c.get("traceId"); // string
return c.json({ traceId });
});Config options: header? (default: "x-trace-id"), generator?
signJwt(payload, secret, opts?) — v0.6.0
JWT를 발급해요. createAuthMiddleware(verify)의 대칭 발급 함수예요.
import { signJwt, createAuthMiddleware } from "@ktds-axbd/harness-kit";
// OAuth callback 후 JWT 발급
const token = await signJwt(
{ sub: profile.id, email: profile.email, role: "member" },
c.env.JWT_SECRET,
{ expiresIn: 3600 }, // 기본값
);Parameters: payload: Omit<JwtPayload, "iat"|"exp">, secret: string, opts.expiresIn?: number (default: 3600s)
createOAuthRoutes(config) — v0.6.0
OAuth 2.0 소셜 로그인 full flow를 Hono sub-app 한 줄로 붙여요.
GET /authorize (provider redirect) + GET /callback (code → JWT) 전체를 캡슐화해요.
import {
createOAuthProvider,
createOAuthRoutes,
GOOGLE_PRESET,
} from "@ktds-axbd/harness-kit";
const app = new Hono<{ Bindings: Env }>();
const googleProvider = createOAuthProvider({
provider: GOOGLE_PRESET,
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: "https://my-service.workers.dev/auth/callback",
usePKCE: true,
});
// 한 줄로 OAuth 로그인 플로우 완성
app.route("/auth", createOAuthRoutes({
provider: googleProvider,
onSuccess: { redirectTo: "/" }, // 또는 "json"
}));
export default app;OAuth flow:
GET /auth/authorize→ state cookie 저장 + 302 to GoogleGET /auth/callback?code=...&state=...→ code 교환 → JWT 발급 → redirect or JSON
Config options:
| 옵션 | 기본값 | 설명 |
|------|--------|------|
| provider (required) | — | OAuthProvider 인스턴스 |
| jwtSecret? | c.env.JWT_SECRET 우선 | JWT 서명 키 |
| jwtExpiresIn? | 3600 | Access token 만료 (초) |
| refreshExpiresIn? | 604800 | Refresh token 만료 (초, 7d) |
| refreshStore? | InMemoryRefreshTokenStore | Refresh token 상태 저장소 |
| refreshCookieName? | "hk_refresh" | Refresh token cookie 이름 |
| refreshSecret? | jwtSecret fallback | Refresh token 전용 서명키 (v0.8.0) |
| refreshGraceMs? | 0 | Grace window (ms); 0 = strict revoke (v0.8.0) |
| cookieName? | "hk_oauth_state" | state cookie 이름 |
| usePKCE? | false | PKCE S256 활성화 |
| mapProfile? | { sub, email, role: "member" } | profile → JWT payload 매핑 |
| onSuccess? | "json" | "json" or { redirectTo: string } |
응답:
GET /authorize→302to providerGET /callback성공 →200 { accessToken, refreshToken, token(deprecated alias), profile }(json) or302 redirectTo+hk_token/hk_refreshcookies- code 누락 →
400, state 불일치 →403, provider 오류 →502 POST /refresh{ refreshToken }→200 { accessToken, refreshToken, token }(rotation) or401(invalid/reuse/revoked)
Refresh Token Rotation — v0.8.0 (RFC 9700)
RFC 9700 기반 stateful refresh token rotation + reuse detection + grace window 제공해요.
import {
createOAuthProvider,
createOAuthRoutes,
InMemoryRefreshTokenStore,
GOOGLE_PRESET,
} from "@ktds-axbd/harness-kit";
// 프로덕션: D1/KV adapter 주입 권장
// InMemoryRefreshTokenStore는 Workers single-isolate 환경에서 상태 유실 가능
const refreshStore = new InMemoryRefreshTokenStore();
app.route("/auth", createOAuthRoutes({
provider: googleProvider,
refreshStore, // 생략 시 자동 InMemory 생성
refreshExpiresIn: 604800, // 7일 (기본값)
refreshSecret: env.REFRESH_SECRET, // v0.8.0: refresh 전용 서명키
refreshGraceMs: 5000, // v0.8.0: 5초 grace window (모바일/SPA 동시 요청 대응)
}));Refresh flow:
/callback→{ accessToken, refreshToken }발급 (각각 별도 서명키 사용 — v0.8.0)POST /auth/refresh{ refreshToken }→ 새{ accessToken, refreshToken }(1회용 회전)- 동일 refresh token 재사용 → grace window 내라면
200이전 신규 토큰 재반환 (idempotent) — v0.8.0 - grace 만료 후 재사용 →
401 { error: "refresh_token_reuse_detected" }+ family 전체 revoke
Grace Window (v0.8.0):
- 모바일/SPA 환경에서 동시 refresh 요청 시 정상 클라이언트가 false-positive revocation 당하는 문제 해소
refreshGraceMs=5000(5초): 첫 회전 후 5초 내 동일 refresh token 재사용 → 이전에 발급된 새 token 재반환refreshGraceMs=0(기본): 즉시 revoke (v0.7.0 strict 동작과 동일)
refreshSecret 격리 (v0.8.0):
refreshSecret설정 시 refresh token을 별도 키로 서명 → access token 서명키 노출이 refresh token을 위협하지 않음- 미설정 시
jwtSecretfallback (v0.7.0 backward compat)
⚠️ 프로덕션 주의사항: InMemoryRefreshTokenStore는 Workers isolate 재시작 시 상태 초기화. 프로덕션에서는 D1/KV adapter를 직접 구현하세요:
class D1RefreshTokenStore implements RefreshTokenStore {
constructor(private db: D1Database) {}
async isRotatedOrRevoked(jti: string): Promise<boolean> {
const row = await this.db.prepare(
"SELECT 1 FROM refresh_tokens WHERE jti = ? AND (rotated = 1 OR family_revoked = 1)"
).bind(jti).first();
return row !== null;
}
// markRotated, revokeFamily, isFamilyRevoked ...
}D1 유틸리티
import { getDb, runQuery, runExec } from "@ktds-axbd/harness-kit/d1";
// Workers fetch handler에서
export default {
async fetch(req: Request, env: Env) {
const db = getDb(env.DB);
const rows = await runQuery<User>(db, "SELECT * FROM users WHERE id = ?", [id]);
return Response.json(rows);
},
};Event Bus
D1 기반 도메인 이벤트 발행/구독이에요.
import { D1EventBus, NoopEventBus, createEvent } from "@ktds-axbd/harness-kit/events";
// 이벤트 발행
const bus = new D1EventBus(env.DB);
await bus.publish(
createEvent("discovery.opportunity.created", "gate-x", {
opportunityId: "opp-123",
title: "AI 기반 BD 자동화",
})
);
// 이벤트 구독
bus.subscribe("discovery.opportunity.created", async (event) => {
console.log("New opportunity:", event.payload);
});
// 폴링으로 미처리 이벤트 처리 (Cron Trigger에서 사용)
await bus.poll("gate-x", 100); // 최대 100건D1 마이그레이션 필요:
-- 0114_domain_events.sql (Foundry-X DB에 이미 적용됨)
CREATE TABLE IF NOT EXISTS domain_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
source TEXT NOT NULL,
tenant_id TEXT NOT NULL DEFAULT 'default',
payload TEXT NOT NULL,
metadata TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL
);ESLint Plugin
서비스 간 직접 import를 방지해요.
// eslint.config.mjs
import { harnessKitPlugin } from "@ktds-axbd/harness-kit/eslint";
export default [
{
plugins: { "harness-kit": harnessKitPlugin },
rules: {
"harness-kit/no-cross-service-import": "error",
},
settings: {
"harness-kit": {
currentService: "gate-x",
// 직접 import 금지 서비스 목록 (기본: 모든 ServiceId)
forbiddenServices: ["foundry-x", "launch-x"],
},
},
},
];Resilience — 외부 호출 견고성 (./resilience, v0.15.0)
외부 API(AI 모델 등) 호출의 재시도·차단·대체 패턴. 무의존(AbortController + setTimeout).
import { withRetry, createCircuitBreaker, withFallback } from "@ktds-axbd/harness-kit/resilience";
// 지수 백오프 + jitter + retryable predicate
const result = await withRetry(() => fetch(url), {
maxAttempts: 3,
baseDelayMs: 200,
jitter: true,
isRetryable: (e) => e instanceof TypeError, // 네트워크 오류만 재시도
});
// 회로 차단기 — closed→open→half-open
const breaker = createCircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 30_000, halfOpenMaxCalls: 2 });
const data = await breaker.execute(() => callExternalApi());
// 폴백 체인 — primary 실패 시 fallback
const value = await withFallback(() => primaryModel(), () => fallbackModel());Webhook 서명 검증 (./middleware, v0.15.0)
수신 webhook의 HMAC 서명을 검증하는 미들웨어. GitHub/Slack/Stripe 프리셋 + Web Crypto timing-safe 비교.
import { createWebhookVerifyMiddleware } from "@ktds-axbd/harness-kit/middleware";
// GitHub (X-Hub-Signature-256)
app.use("/webhooks/github", createWebhookVerifyMiddleware({ provider: "github", secret: c.env.GH_WEBHOOK_SECRET }));
// 검증 실패 시 401, 성공 시 다음 핸들러로 (raw body 보존)KV Store (./kv, v0.15.0)
Cloudflare KV 바인딩 helper. namespace prefix + JSON + TTL. ./d1 대칭.
import { createKVStore } from "@ktds-axbd/harness-kit/kv";
const kv = createKVStore(c.env.CACHE, { prefix: "session:" });
await kv.setJSON("user-123", { name: "..." }, { ttlSeconds: 3600 });
const user = await kv.getJSON<User>("user-123"); // prefix 자동 부착, miss 시 nullgetOrFetch cache-aside (v0.21.0+) — 캐시 hit 시 반환, miss 시 fetcher 실행 후 저장하고 결과 반환. raw JSON(envelope 없음) + prefix·TTL 적용.
const profile = await kv.getOrFetch<Profile>(
"user-123",
async () => fetchProfileFromD1("user-123"), // miss 시에만 실행
3600, // TTL(초)
);Health/Readiness probe (./middleware, v0.16.0)
liveness(즉시 200) + readiness(의존성 체크 콜백). 로드밸런서·Cloudflare 헬스체크·k8s 스타일 probe.
import { createHealthMiddleware } from "@ktds-axbd/harness-kit/middleware";
const health = createHealthMiddleware({
checks: [
{ name: "d1", check: async () => { await c.env.DB.prepare("SELECT 1").first(); } },
{ name: "kv", check: async () => { await c.env.CACHE.get("ping"); } },
],
// livenessPath: "/health" (기본), readinessPath: "/ready" (기본)
});
app.route("/", health); // GET /health → 200, GET /ready → 200 또는 503 + 실패 항목API Key auth (./middleware, v0.16.0)
M2M 인증. 발급·검증·rotation. 원본 키는 1회만 반환, KV에는 SHA-256 해시만 저장.
import { createApiKeyMiddleware, issueApiKey, rotateApiKey } from "@ktds-axbd/harness-kit/middleware";
// 발급 (원본 키는 이때 1회만 노출)
const { apiKey, id } = await issueApiKey({ kv: c.env.KEYS, owner: "svc-a", options: { scopes: ["read"] } });
// 검증 미들웨어 (X-API-Key 헤더)
app.use("/api/*", createApiKeyMiddleware({ kvBinding: "KEYS", bypassPaths: ["/api/health"] }));
// 통과 시 c.get("apiKeyId")·c.get("owner")·c.get("scopes"), 실패 시 401
// rotation (신규 발급 + 기존 revoke)
const rotated = await rotateApiKey({ kv: c.env.KEYS, id, owner: "svc-a" });Pagination (./pagination, v0.16.0)
cursor·offset 양 모드 순수 유틸. ./d1와는 SQL fragment 반환으로 연동(직접 import 없음).
import { parsePaginationParams, buildPageResponse, buildD1PaginationClause } from "@ktds-axbd/harness-kit/pagination";
const params = parsePaginationParams(c.req.query()); // { limit, cursor?, offset? } (limit max 100 clamp)
const { clause, binds } = buildD1PaginationClause({ ...params, orderBy: "id" });
const rows = await c.env.DB.prepare(`SELECT * FROM items ${clause}`).bind(...binds).all();
return c.json(buildPageResponse(rows.results, { ...params, cursorField: "id" })); // { data, pageInfo: { hasNextPage, nextCursor } }R2 Store (./r2, v0.16.0)
Cloudflare R2 바인딩 helper. ./kv 대칭. presigned URL은 R2 바인딩 미지원(Worker proxy 패턴 별도).
import { createR2Store } from "@ktds-axbd/harness-kit/r2";
const r2 = createR2Store(c.env.BUCKET, { prefix: "uploads/" });
await r2.put("a.json", JSON.stringify(data), { httpMetadata: { contentType: "application/json" } });
const obj = await r2.getJSON<Data>("a.json"); // prefix 자동 부착
const page = await r2.list({ prefix: "2026/", limit: 100 }); // { objects, truncated, cursor }Queue (./queue, v0.16.0)
Cloudflare Queue producer + consumer helper.
import { createQueueProducer, createQueueConsumer } from "@ktds-axbd/harness-kit/queue";
// Producer
const q = createQueueProducer(c.env.MY_QUEUE);
await q.send({ jobId: 1 });
await q.sendBatch([{ jobId: 2 }, { jobId: 3 }], { delaySeconds: 30 });
// Consumer (메시지별 ack 성공 / retry throw 격리)
export const queue = createQueueConsumer<Job>(async (body) => { await process(body); });Structured Logger (./middleware, v0.17.0)
요청별 구조화 JSON 로거. traceId 자동 바인딩. 핸들러에서 getLogger(c)로 호출.
import { createLoggerMiddleware, getLogger } from "@ktds-axbd/harness-kit/middleware";
app.use("*", createTraceIdMiddleware());
app.use("*", createLoggerMiddleware({ service: "gate-x", minLevel: "info", redact: ["password"] }));
app.get("/api/data", (c) => {
getLogger(c).info("fetch data", { userId: c.get("userId") }); // { level, msg, traceId, service, userId, timestamp }
return c.json({ ok: true });
});Cron/Scheduled handler (./cron, v0.17.0)
Workers cron 트리거 래퍼. name별 라우팅 + job 에러 격리.
import { createScheduledHandler } from "@ktds-axbd/harness-kit/cron";
export const scheduled = createScheduledHandler({
jobs: [
{ name: "hourly-sync", cron: "0 * * * *", handler: async (ctx) => { await sync(ctx.env); } },
{ name: "daily-report", cron: "0 0 * * *", handler: async (ctx) => { await report(ctx.env); } },
],
}); // event.cron으로 매칭 job 실행, 1개 throw가 타 job 미차단, ctx.waitUntilschedule predicate (v0.22.0): 단일 cron 안에서 시간/조건 제약을 선언적으로 표현. 예를 들어 6시간마다 도는 cron에 백업 job만 UTC 18시에 실행하고 싶을 때, handler 내부 if (getUTCHours() !== 18) return 대신:
createScheduledHandler({
jobs: [
{ name: "recon", cron: "0 */6 * * *", handler: reconJob }, // 매 실행
{
name: "backup",
cron: "0 */6 * * *",
schedule: () => new Date().getUTCHours() === 18, // UTC 18시에만
handler: backupJob,
},
],
}); // schedule false면 cron 매칭이어도 skip + logger.info("cron skip")타입 export (v0.22.0): ScheduledEventLike/ExecutionContextLike를 export해, 반환값을 export const scheduled = ...로 내보낼 때 declaration emit의 private name leak(TS4023/TS4082)을 방지. Cloudflare 표준 타입(ScheduledController/ExecutionContext)도 구조적으로 호환돼요.
Metrics scrape endpoint (./metrics, v0.17.0)
Prometheus pull(/metrics) 엔드포인트. otel push와 별개.
import { createMetricsRegistry, createMetricsEndpoint } from "@ktds-axbd/harness-kit/metrics";
const metrics = createMetricsRegistry();
const reqCount = metrics.counter("http_requests_total", "총 요청 수", ["method"]);
reqCount.inc({ method: "GET" });
app.route("/", createMetricsEndpoint(metrics)); // GET /metrics → Prometheus text formatWebSocket/DO helper (./websocket, v0.17.0)
WebSocket 업그레이드 + 룸 broadcast(Durable Object 호환).
import { createWebSocketUpgrade, createBroadcastHub } from "@ktds-axbd/harness-kit/websocket";
const hub = createBroadcastHub();
app.get("/ws", (c) =>
createWebSocketUpgrade({ // non-WS 요청 시 426
onOpen: (ws) => hub.add(ws),
onMessage: (ws, msg) => hub.broadcast(msg), // 전체 broadcast
onClose: (ws) => hub.remove(ws), // 자동 remove
})(c.req.raw), // 101 Response 반환
);Validation (./middleware, v0.18.0)
duck-typed schema(zod·valibot 호환) 요청 검증. zod 미import = dependency-free.
import { createValidatorMiddleware, getValidated } from "@ktds-axbd/harness-kit/middleware";
import { z } from "zod"; // 소비자가 제공 (harness-kit는 zod 비의존)
const schema = z.object({ name: z.string(), age: z.number() });
app.post("/users", createValidatorMiddleware("json", schema), (c) => {
const body = getValidated<{ name: string; age: number }>(c, "json"); // 검증된 타입 데이터
return c.json({ ok: true, body });
}); // 검증 실패 시 400 + { issues: [...] }Config/Secrets (./config, v0.18.0)
Workers env 타입 로더. required fail-fast + transform + secret 마스킹.
import { createConfig } from "@ktds-axbd/harness-kit/config";
const config = createConfig(c.env, {
API_URL: { required: true },
MAX_RETRIES: { default: "3", transform: Number },
JWT_SECRET: { required: true, secret: true }, // 로그에서 *** 마스킹
});
config.get("API_URL"); // string (없으면 startup throw)
config.get("MAX_RETRIES"); // 3 (number)
console.log(config.toJSON()); // { ..., JWT_SECRET: "***" }Service Binding/RPC (./service-binding, v0.18.0)
MSA Worker-to-Worker service binding 호출 클라이언트.
import { createServiceClient } from "@ktds-axbd/harness-kit/service-binding";
const agent = createServiceClient(c.env.AGENT_SVC, { // Cloudflare service binding (Fetcher)
basePath: "/api/agent",
headers: { authorization: c.req.header("authorization") ?? "" }, // auth 전파
});
const result = await agent.post<{ id: string }>("/run", { task: "x" }); // 타입 JSON, non-2xx → throwObservability — OTel export (v0.9.0)
createTraceIdMiddleware + createRequestLoggerMiddleware가 만든 데이터를 OpenTelemetry SDK 의존성 없이 OTLP/JSON wire format으로 collector에 직접 전송해요 (fetch 기반, bundle footprint 추가 ≈ 0).
import {
createOtelTraceMiddleware,
exportMetrics,
buildRequestCountMetric,
buildDurationHistogramMetric,
} from "@ktds-axbd/harness-kit/otel";
// 요청당 span을 OTLP collector로 전송 (trace-id + method/path/status/duration → span attributes)
app.use("*", createOtelTraceMiddleware({
endpoint: "https://collector.example.com", // POST {endpoint}/v1/traces
serviceName: "my-service",
headers: { "x-api-key": env.OTEL_API_KEY },
resourceAttributes: { "deployment.environment": "production" },
}));Signals: traces(요청 span) + metrics(request count sum + duration histogram). exportTraces/exportMetrics가 OTLP/JSON payload를 /v1/traces·/v1/metrics로 POST하고, span flush는 ctx.waitUntil로 응답 지연 없이 처리해요.
의존성 0: @microlabs/otel-cf-workers·@opentelemetry/* 등 SDK 미사용 — OTLP/JSON(resourceSpans/resourceMetrics) 구조를 직접 구성. peerDependency 추가 없음.
W3C Trace Context — traceparent 전파 (v0.10.0)
인바운드 traceparent 헤더를 파싱하여 분산 트레이싱의 parent-child span 연결을 지원해요.
import {
createOtelTraceMiddleware,
getTraceparent,
parseTraceparent,
buildTraceparent,
} from "@ktds-axbd/harness-kit/otel";
// 1. Middleware 등록 — 인바운드 traceparent 자동 파싱
app.use("*", createOtelTraceMiddleware({ endpoint: "https://collector.example.com", serviceName: "svc-a" }));
// 2. 아웃바운드 fetch에 traceparent 전파 (downstream service와 trace 연결)
app.get("/api/data", async (c) => {
const res = await fetch("https://svc-b.example.com/api/items", {
headers: { traceparent: getTraceparent(c) ?? "" },
});
return c.json(await res.json());
});
// 3. 직접 파싱 / 생성 유틸
const parsed = parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
// → { traceId: "4bf92f...", parentSpanId: "00f067...", flags: "01" }
// all-zero traceId/spanId, 잘못된 포맷 → null
const header = buildTraceparent("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7");
// → "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"인바운드 traceparent가 유효하면 span이 기존 traceId를 이어받고 parentSpanId가 설정되어 parent-child 관계가 형성돼요. 유효하지 않거나 없으면 새 root span이 생성돼요.
OTLP Logs export — log-trace correlation (v0.11.0)
세 번째 OTLP signal인 logs를 추가해요. createOtelTraceMiddleware의 includeLogs 옵션(기본 true)으로 요청당 LogRecord 1개를 collector로 전송하며, 같은 요청 span의 traceId/spanId를 담아 **trace와 자동 상관(correlation)**돼요.
import {
createOtelTraceMiddleware,
exportLogs,
buildLogRecord,
} from "@ktds-axbd/harness-kit/otel";
// includeLogs 기본 true — traces·metrics와 동일 미들웨어에서 요청당 LogRecord 발행
app.use("*", createOtelTraceMiddleware({
endpoint: "https://collector.example.com", // POST {endpoint}/v1/logs
serviceName: "my-service",
includeLogs: true, // 기본값 (includeMetrics와 동형, false로 끄기 가능)
}));LogRecord: body "METHOD path STATUS", severity는 status 기반 매핑(5xx → ERROR(17) · 4xx → WARN(13) · 그 외 → INFO(9)). traceId/spanId가 설정되어 collector backend(Tempo/Loki 등)에서 trace ↔ log 연결이 가능해요.
Signals (3종): traces(요청 span) + metrics(count sum + duration histogram) + logs(요청당 LogRecord). 각각 exportTraces/exportMetrics/exportLogs가 /v1/traces·/v1/metrics·/v1/logs로 OTLP/JSON POST하고, flush는 모두 ctx.waitUntil로 응답 지연 없이 처리해요. 의존성 0 유지 (manual OTLP/JSON, resourceLogs/scopeLogs/logRecords 구조 직접 구성).
Per-Request 테넌트 라우팅 (v0.12.0 — 멀티테넌트 관측)
createMultiTenantMiddleware와 createOtelTraceMiddleware를 연동하면 요청마다 orgId를 동적으로 추출해 traces·metrics·logs 3 signal 전부를 별도 테넌트 백엔드로 라우팅해요. Tempo·Mimir·Loki 멀티테넌시를 단일 Collector 인스턴스로 구현할 수 있어요.
통합 예제
import { createHarness } from "@ktds-axbd/harness-kit";
import { createMultiTenantMiddleware, createOtelTraceMiddleware } from "@ktds-axbd/harness-kit";
const config = { serviceName: "my-service", serviceId: "my-service", corsOrigins: ["*"] };
const app = createHarness(config);
// 1. 테넌트 컨텍스트 추출 (X-Org-ID 헤더 → c.set("orgId", ...))
app.use("*", createMultiTenantMiddleware({
headerName: "X-Org-ID", // 소비자가 보내는 헤더
contextKey: "orgId", // c.get("orgId")로 접근
defaultOrgId: "default",
}));
// 2. OTel 트레이싱 + 테넌트 라우팅
app.use("*", createOtelTraceMiddleware(config, {
endpoint: "http://localhost:4318", // OTel Collector
tenantContextKey: "orgId", // createMultiTenantMiddleware의 contextKey와 일치
tenantHeaderName: "X-Scope-OrgID", // Grafana 스택 표준 헤더 (Mimir·Loki·Tempo)
defaultOrgId: "default", // 테넌트 컨텍스트 없을 때 fallback (헤더 생략)
}));동작 원리
| 단계 | 설명 |
|------|------|
| 요청 수신 | createMultiTenantMiddleware가 X-Org-ID 헤더를 읽어 c.set("orgId", orgId) |
| flush 시점 | ctx.waitUntil로 비동기 실행 — c.get("orgId")로 orgId 획득 |
| 헤더 주입 | withTenantHeader(config, orgId, "X-Scope-OrgID")로 각 signal의 HTTP 헤더에 주입 |
| 테넌트 격리 | Tempo: X-Scope-OrgID → Mimir: X-Scope-OrgID → Loki: X-Scope-OrgID |
No-context Fallback
tenantContextKey로 조회된 값이 없으면:
defaultOrgId가 설정된 경우 →X-Scope-OrgID: defaultOrgId주입defaultOrgId도 없으면 → 테넌트 헤더 생략 (단일 테넌트 동작)
Grafana 관측 환경 (v0.12.0 — dashboards-as-code)
examples/observability/ 디렉토리에 Grafana as-code provisioning 파일이 포함돼 있어요. docker compose up 한 번으로 완전한 관측 스택을 재현할 수 있어요.
npm 설치 후 node_modules/@ktds-axbd/harness-kit/examples/observability/에서 바로 사용할 수 있어요.
포함 서비스
| 서비스 | 이미지 | 포트 | 역할 |
|--------|--------|------|------|
| Grafana | grafana/grafana:11.3.1 | :3000 | 대시보드 + datasource 관리 |
| Tempo | grafana/tempo:2.6.1 | :3200 / :4317 | 분산 추적 |
| Loki | grafana/loki:3.2.0 | :3100 | 로그 수집 |
| Prometheus | prom/prometheus:v2.53.0 | :9090 | 메트릭 수집 |
| Mimir | grafana/mimir:2.14.0 | :9009 | 멀티테넌트 메트릭 |
| OTel Collector | otel/opentelemetry-collector-contrib:latest | :4318 | 신호 수집·라우팅 |
로컬 실행
# npm 설치 후
cd node_modules/@ktds-axbd/harness-kit/examples/observability
docker compose up -d
# Grafana UI: http://localhost:3000 (anonymous admin)
# RED 대시보드: http://localhost:3000/d/harness-kit-red기존 Grafana에 붙이기
grafana/provisioning/ 파일을 기존 Grafana 인스턴스의 provisioning 디렉토리에 복사하면 Tempo·Loki·Prometheus datasource와 RED 대시보드를 자동으로 로드해요.
cp -r examples/observability/grafana/provisioning /etc/grafana/provisioning
cp -r examples/observability/grafana/dashboards /etc/grafana/dashboards
# Grafana 재시작HarnessConfig 타입
interface HarnessConfig {
serviceName: string; // 서비스 표시명
serviceId: ServiceId; // 서비스 식별자
corsOrigins: string[]; // 허용 CORS Origin 목록
publicPaths?: string[]; // 인증 불필요 경로 (prefix 매칭)
jwtAlgorithm?: string; // JWT 알고리즘 (기본: "HS256")
}OAuth 2.0 Provider
Workers 환경(fetch + crypto.subtle)에서 Google/GitHub 소셜 로그인을 처리하는 helper예요.
기본 흐름
import { createOAuthProvider, generatePKCE, generateState, verifyState } from "@ktds-axbd/harness-kit";
const oauth = createOAuthProvider({
provider: "google", // "google" | "github" | GenericProviderSpec
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
redirectUri: "https://myapp.example.com/callback",
usePKCE: true,
});
// Step 1: Authorization URL 생성
const pkce = await generatePKCE();
const state = generateState();
const { url } = oauth.getAuthorizationUrl({ state, codeVerifier: pkce.verifier });
// state/verifier를 KV에 저장 후 redirect
// Step 2: Callback — code 교환
const tokens = await oauth.exchangeCode({ code, codeVerifier: storedVerifier });
// Step 3: 사용자 정보
const profile = await oauth.getUserInfo(tokens.accessToken);
// profile: { id, email, name, avatarUrl?, raw }JwtPayload 연동
const payload = { sub: profile.id, email: profile.email ?? "", role: "member" as const };Feature Flags — OpenFeature 어댑터 (v0.12.0)
./flags 모듈은 의존성 없는 feature-flag 평가(boolean·string·number·JSON + deterministic rollout + targeting)를 제공하며, HarnessKitProvider로 OpenFeature SDK에 연결할 수 있어요. Cloudflare Flagship GA 시 FlagshipProvider로 교체 가능한 표준 어댑터예요.
설치 (OpenFeature 어댑터 사용 시)
@openfeature/server-sdk는 optional peerDependency예요 — 어댑터를 쓸 때만 설치하면 돼요.
npm install @openfeature/server-sdk사용 예
import { OpenFeature } from "@openfeature/server-sdk";
import { HarnessKitProvider, StaticFlagProvider } from "@ktds-axbd/harness-kit/flags";
// 1) harness FlagProvider 정의 (Static 또는 KVFlagProvider)
const flagProvider = new StaticFlagProvider({
"new-dashboard": {
key: "new-dashboard",
defaultValue: false,
rules: [{ when: { orgId: "org-123" }, value: true, variant: "treatment" }],
},
});
// 2) OpenFeature SDK에 harness-kit Provider 등록
await OpenFeature.setProviderAndWait(new HarnessKitProvider(flagProvider));
// 3) 표준 OpenFeature client API로 평가
const client = OpenFeature.getClient();
const enabled = await client.getBooleanValue("new-dashboard", false, {
targetingKey: userId, // → harness EvaluationContext.userId
orgId: "org-123", // → harness EvaluationContext.orgId
});HarnessKitProvider는 4종 resolve(Boolean/String/Number/Object)를 지원하며, harness ResolutionDetails(value·reason·variant)를 OpenFeature 표준 형식으로 전파해요. 어댑터를 거치지 않고 isEnabled(c, key)·getFlag(c, key) 미들웨어 헬퍼를 직접 쓸 수도 있어요.
createSecurityHeadersMiddleware(config?) (F715)
보안 헤더 주입 미들웨어. CSP·HSTS·X-Frame-Options·X-Content-Type-Options·Referrer-Policy를 응답에 추가해요.
import { createSecurityHeadersMiddleware } from "@ktds-axbd/harness-kit";
// 또는 subpath: import { createSecurityHeadersMiddleware } from "@ktds-axbd/harness-kit/middleware";
app.use("*", createSecurityHeadersMiddleware({
csp: "default-src 'self'; script-src 'self' 'nonce-{n}'", // false로 비활성화
hsts: { maxAge: 63072000, includeSubDomains: true },
xFrameOptions: "DENY", // "SAMEORIGIN" | false
xContentTypeOptions: true, // nosniff (기본값)
referrerPolicy: "strict-origin-when-cross-origin", // false로 비활성화
}));기본값: CSP default-src 'self', X-Frame-Options DENY, nosniff true, Referrer-Policy strict-origin-when-cross-origin. HSTS는 기본 비활성화.
조건부 CSP (v0.19.0+) — csp에 콜백을 전달하면 응답 컨텍스트 기반으로 CSP를 동적 결정해요. HTML 문서(예: Swagger UI)는 제외하고 JSON API에만 strict CSP를 적용하는 등 HTML+JSON 혼재 표면에 유용해요. 콜백이 false/undefined를 반환하면 CSP 헤더를 설정하지 않아요.
app.use("*", createSecurityHeadersMiddleware({
// JSON 응답만 strict CSP, HTML(Swagger UI 등)은 제외
csp: (c) => (c.res.headers.get("Content-Type") ?? "").includes("text/html")
? false
: "default-src 'none'",
}));createTimeoutMiddleware(config) (F715)
요청 타임아웃 미들웨어. 핸들러가 timeoutMs 초과 시 504 Gateway Timeout을 반환해요.
import { createTimeoutMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createTimeoutMiddleware({
timeoutMs: 5000,
message: "Request timed out", // 기본: "Gateway Timeout"
skipPaths: ["/health", "/api/stream"],
}));createBodyLimitMiddleware(config) (F715)
요청 바디 크기 제한 미들웨어. Content-Length 초과 시 413 Payload Too Large를 반환해요. (DoS 방어)
import { createBodyLimitMiddleware } from "@ktds-axbd/harness-kit";
app.use("*", createBodyLimitMiddleware({
maxBytes: 10_000_000, // JSON 기본 10MB
message: "File too large",
methods: ["POST", "PUT", "PATCH"], // 기본값
skipPaths: ["/api/health"], // v0.19.0+ — 한도 완전 면제 경로
pathLimits: [ // v0.20.0+ — prefix별 차등 한도
{ prefix: "/api/files/", maxBytes: 50_000_000 }, // 업로드 50MB
{ prefix: "/api/launch/object-store/", maxBytes: 50_000_000 },
],
}));Content-Length 헤더가 없으면 passthrough. GET/DELETE 등은 기본 제한 제외. skipPaths (v0.19.0+) — 업로드 등 큰 body가 정상인 경로를 한도 검사에서 완전 면제해요. pathLimits (v0.20.0+) — 경로 prefix별 차등 한도를 적용해요(예: JSON 10MB, 업로드 50MB). 여러 prefix 매칭 시 가장 긴(구체적인) prefix가 우선(longest-prefix-match), skipPaths와 병존(skipPaths 먼저 검사). skipPaths로 업로드를 면제하면 ceiling이 사라지는 문제를 pathLimits로 해소(업로드도 한도 유지).
createIdempotencyMiddleware(config?) (F716)
POST/PATCH 중복 실행 방지 미들웨어. Idempotency-Key 헤더 기반으로 첫 요청 응답을 저장하고, 동일 키 재요청 시 replay해요. in-flight 중 동일 키 요청은 409를 반환해요.
import { createIdempotencyMiddleware } from "@ktds-axbd/harness-kit";
// 또는 subpath: import { createIdempotencyMiddleware } from "@ktds-axbd/harness-kit/middleware";
app.use("*", createIdempotencyMiddleware({
kvBinding: "IDEMPOTENCY_KV", // Cloudflare KV namespace binding (필수, 없으면 passthrough)
ttlSec: 86400, // 캐시 TTL (기본: 86400초 = 24시간)
keyHeader: "Idempotency-Key", // 헤더 이름 (기본값)
methods: ["POST", "PATCH"], // 적용 메서드 (기본값)
}));동작 요약:
kvBinding미설정 → passthrough (idempotency 비활성화)Idempotency-Key헤더 없음 → passthrough- 첫 요청 → handler 실행, KV에 응답 저장
- 동일 키 재요청 → 저장된 응답 replay (handler 미실행)
- in-flight 중 동일 키 →
409 Conflict - TTL 만료 → 키 소멸, 새 요청으로 처리
Subpath Exports
// middleware subpath (F715+)
import { createSecurityHeadersMiddleware, createTimeoutMiddleware, createBodyLimitMiddleware, createIdempotencyMiddleware } from "@ktds-axbd/harness-kit/middleware";
// oauth subpath (F715+)
import { createOAuthProvider, createOAuthRoutes } from "@ktds-axbd/harness-kit/oauth";Resilience — 외부 호출 견고성 (v0.15.0+)
./resilience subpath export — 무의존(AbortController + setTimeout), Cloudflare Workers 호환.
import { withRetry, createCircuitBreaker, withFallback } from '@ktds-axbd/harness-kit/resilience'withRetry
지수 백오프 + jitter + AbortSignal 취소 지원.
const result = await withRetry(
() => fetchExternalAPI(),
{
maxAttempts: 5, // 최대 시도 횟수
baseDelayMs: 100, // 기본 지연 ms (기본값: 100)
maxDelayMs: 10000, // 최대 지연 ms (기본값: 10000)
jitter: true, // 지터 활성화 (기본값: true, thundering herd 방지)
signal: abortSignal, // 취소 신호 (선택)
retryable: (err) => err instanceof NetworkError, // 재시도 가능 여부 (선택)
}
)createCircuitBreaker
closed → open → half-open 상태머신. cascade failure 차단.
const cb = createCircuitBreaker({
failureThreshold: 5, // open 전환 실패 횟수
resetTimeoutMs: 10000, // open → half-open 대기 ms
halfOpenMaxCalls: 1, // half-open 허용 호출 수 (기본값: 1)
})
try {
const result = await cb.execute(() => callExternalService())
} catch (err) {
if (err instanceof CircuitOpenError) {
// 회로 열림 — 빠른 실패
}
}
cb.getState() // 'closed' | 'open' | 'half-open'
cb.reset() // 강제 CLOSED 복귀withFallback
primary 실패 시 fallback chain 순차 실행.
const result = await withFallback(
() => callPrimaryAPI(), // 1순위
() => callSecondaryAPI(), // 2순위 (primary 실패 시)
() => Promise.resolve(cache), // 최종 fallback
)조합 패턴
const cb = createCircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 5000 })
const result = await withFallback(
() => withRetry(() => cb.execute(() => callAI()), { maxAttempts: 3 }),
() => Promise.resolve(cachedResponse),
)버전 정보
| 버전 | 변경사항 |
|------|---------|
| 0.13.0 | production middleware 3종(security-headers·timeout·body-limit) + ./middleware·./oauth subpath exports |
| 0.12.0 | OpenFeature 어댑터 — HarnessKitProvider (@openfeature/server-sdk optional peerDep) |
| 0.5.0 | OAuth 2.0 provider (Google/GitHub preset + PKCE S256 + state CSRF) |
| 0.1.0 | 초기 릴리스 — Phase 20 AX BD MSA 재조정 |
