npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@reopt-ai/data-sdk-server

v0.5.0

Published

reopt-data analytics SDK for Next.js server components, route handlers, proxy and plain Node

Readme

@reopt-ai/data-sdk-server

reopt-data의 서버 쪽 SDK입니다. Next.js 서버 컴포넌트·라우트 핸들러·proxy.ts·instrumentation.ts, 그리고 Next.js가 아닌 순수 Node 프로세스(워커·크론·스크립트)에서 씁니다.

브라우저에서 도는 코드는 @reopt-ai/data-sdk-client를 쓰세요. 두 패키지를 나눈 이유는 번들 크기가 아니라 경계의 강제입니다 — 이 패키지의 루트는 server-only를 import하므로, 클라이언트 컴포넌트에서 실수로 import하면 런타임이 아니라 빌드가 실패합니다. clientSecret이 브라우저 번들에 실려 나가는 일이 생길 수 없습니다.

설치

pnpm add @reopt-ai/data-sdk-server

진입점

| 진입점 | 어디서 쓰나 | 클라이언트 컴포넌트에서 | | --------------------------------- | ------------------------------------------------- | ----------------------- | | @reopt-ai/data-sdk-server | 서버 컴포넌트 · 라우트 핸들러 · instrumentation | 빌드 에러 | | @reopt-ai/data-sdk-server/proxy | proxy.ts (Next 16 — 구 middleware.ts) | 해당 없음 | | @reopt-ai/data-sdk-server/node | 워커 · 크론 · 스크립트 | 빌드 에러 |

1. 요청 스코프 클라이언트

한 번, 모듈 스코프에서 만듭니다. 엔진과 요청별 리졸버 캐시가 여기에 매달리므로 요청마다 다시 만들면 배칭과 세션 조회 캐시를 모두 잃습니다.

// lib/reopt.ts
import "server-only";
import { createReopt } from "@reopt-ai/data-sdk-server";
import { auth } from "@/auth";

export const { getReopt, getBootstrap } = createReopt({
  clientId: process.env.REOPT_CLIENT_ID!,
  clientSecret: process.env.REOPT_CLIENT_SECRET!,
  // 쿠키 이름을 유도하는 값 — 브라우저 SDK와 반드시 같아야 합니다
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  baseUrl: process.env.REOPT_BASE_URL!,
  getProfileId: async () => (await auth())?.user?.id ?? null,
});
// app/api/checkout/route.ts
import { getReopt } from "@/lib/reopt";

export async function POST() {
  const reopt = await getReopt();
  reopt.track("checkout_started"); // deviceId·profileId 자동 바인딩
}

세 개의 식별자가 나오는데 서로 다른 것들입니다:

| 값 | 무엇인가 | | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | | clientId | API client 행의 id입니다 (Client.id, cuid). writeKey가 아닙니다 — 대시보드에서 서버 client를 만들 때 함께 받는 값입니다 | | clientSecret | 그 행의 서버 비밀값(대시보드 표기: serverSecret). 브라우저 번들에 절대 넣지 마세요 | | writeKey | 브라우저용 공개 키. 서버에서는 인증이 아니라 쿠키 이름 유도에만 씁니다 |

getReopt()가 하는 일:

  1. cookies()에서 방문자의 device·consent를 읽습니다.
  2. opt-out이면 아무것도 보내지 않는 no-op 클라이언트를 줍니다. 호출부에 동의 분기를 강요하지 않습니다 — 잊어버리는 그 한 곳이 곧 유출입니다.
  3. getProfileId 리졸버를 요청당 한 번만 돌립니다(headers() 객체를 키로 하는 WeakMap). 결과는 쿠키의 profileId를 덮어씁니다. 쿠키는 브라우저가 쓰고, 세션은 아니기 때문입니다.
  4. 리졸버가 던진 에러 중 Next의 제어흐름(redirect()·notFound()·동적 렌더 bailout)은 그대로 다시 던집니다. 삼키면 리다이렉트가 빈 페이지가 됩니다. 그 외의 에러는 경고만 남기고 익명으로 폴백합니다 — 분석이 500을 낼 이유는 없습니다.
  5. waitUntil을 자동 감지합니다. Vercel 함수는 응답을 반환하는 순간 얼어붙기 때문에, 이게 없으면 fire-and-forget track()조용히 유실됩니다.

2. Bootstrap — 첫 렌더부터 서버와 브라우저가 같은 사람을 봅니다

// app/layout.tsx (서버 컴포넌트)
import { getBootstrap } from "@/lib/reopt";
import { ReoptProvider, ReoptPageView } from "@reopt-ai/data-sdk-client/next";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const bootstrap = await getBootstrap();

  return (
    <html>
      <body>
        <ReoptProvider
          config={{ writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!, baseUrl: "/ingest" }}
          bootstrap={bootstrap}
        >
          <ReoptPageView />
          {children}
        </ReoptProvider>
      </body>
    </html>
  );
}

이 패키지는 React 컴포넌트를 내보내지 않습니다. 서버가 클라이언트 컴포넌트를 감싸 주면 경계가 패키지 안으로 숨습니다. 여기서는 RSC → 클라이언트 경계가 여러분의 layout.tsx에 그대로 보입니다.

getBootstrap()은 device 쿠키가 없으면 **null**을 반환합니다(첫 방문 + 시딩 끔, 또는 proxy matcher 밖). 서버가 임의로 id를 만들어 넘기면 브라우저가 자기 것을 또 만들어 영영 수렴하지 않기 때문에, "아무것도 모른다"를 그대로 전달하는 편이 낫습니다.

3. proxy.ts — 쿠키 시딩과 1st-party ingest

// proxy.ts  (Next 16에서 middleware.ts를 대체합니다. 기본 런타임은 Node)
import { reoptProxy } from "@reopt-ai/data-sdk-server/proxy";

export const proxy = reoptProxy({
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  baseUrl: process.env.REOPT_BASE_URL!,
  proxy: true, // /ingest/* → ${baseUrl}/* (the client sends /ingest/api/track)
});

export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };

두 가지 일을 합니다:

  • device 쿠키 시딩 — 첫 방문에 uuidv7을 심습니다. 렌더 전에 심기 때문에 첫 페이지뷰부터 서버와 브라우저가 같은 id를 씁니다. 동의 전에 쿠키를 쓸 수 없다면 seedDeviceCookie: false.
  • opt-out 시 쿠키 삭제 — 동의 철회는 "안 쓰기"가 아니라 "지우기"입니다. 남겨 두면 매 요청마다 계속 식별하게 됩니다.
  • ingest 프록시/ingest/*를 여러분 도메인에서 받아 rewrite합니다. 클라이언트는 ${baseUrl}/api/track으로 보내므로 baseUrl: "/ingest"이면 실제 요청은 /ingest/api/track이고, prefix만 벗겨 <reopt-data>/api/track이 됩니다.
  • 목적지는 두 번 검사합니다. baseUrl 오리진을 벗어나면(//evil.example 같은 protocol-relative 경로) 400, 오리진 안이라도 /api/ 아래가 아니면 400입니다. URL..를 미리 정규화해 없애기 때문에 /ingest/../../dashboard는 같은 오리진의 /dashboard로 떨어지는데, 대시보드·export·realtime은 각자의 세션으로 직접 접근할 대상이지 고객 도메인을 거쳐 도달할 것이 아닙니다.

다른 proxy 로직과 합성할 때는 응답을 두 번째 인자로 넘깁니다:

const withReopt = reoptProxy({ writeKey, baseUrl, proxy: true });

export function proxy(request: NextRequest) {
  const response = NextResponse.next();
  response.headers.set("x-custom", "value");
  return withReopt(request, response);
}

옵션이 아니라 인자인 이유: 옵션은 팩토리 생성 시 한 번 해석되는데 NextResponse는 요청당 하나입니다.

이미 proxy.ts를 쓰고 있는 앱에 얹기

Next는 파일당 proxy 함수를 하나만 허용합니다. 호스트 라우팅이나 인증 rewrite로 proxy.ts가 이미 차 있는 앱이 대부분이므로, reoptProxy는 default export를 가져가지 않고 기존 함수의 마지막 단계로 들어갑니다.

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { reoptProxy } from "@reopt-ai/data-sdk-server/proxy";
import { resolveTenant } from "./lib/tenant";

const withReopt = reoptProxy({
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  baseUrl: process.env.REOPT_BASE_URL!,
  proxy: true,
});

export default function proxy(request: NextRequest) {
  // 1) 기존 로직이 먼저. rewrite/redirect로 끝나는 요청은 그대로 반환합니다 —
  //    응답 본문이 이 앱에서 나오지 않으므로 심을 쿠키도 없습니다.
  const tenant = resolveTenant(request);
  if (tenant.redirect) return NextResponse.redirect(tenant.redirect);
  if (tenant.rewrite) return NextResponse.rewrite(tenant.rewrite);

  // 2) 이 앱이 렌더할 요청에만 reopt를 얹습니다. 헤더·쿠키를 이미 얹어 둔
  //    응답을 그대로 넘기면 reopt는 device 쿠키만 추가하고 나머지는 보존합니다.
  const response = NextResponse.next();
  response.headers.set("x-tenant", tenant.id);
  return withReopt(request, response);
}

마지막에 두는 이유: withReopt가 반환한 응답에 이후 로직이 또 손대면 순서는 지켜지지만, 앞 단계가 redirect/rewrite를 반환했는데 그걸 withReopt에 넘기면 쿠키가 브라우저에 닿지 않는 응답에 붙습니다. "이 앱이 렌더하는 요청"에만 얹는 것이 규칙입니다.

한 배포가 여러 프로젝트를 서빙할 때

호스트마다 다른 브랜드를 서빙하는 앱은 writeKey를 env에서 읽을 수 없습니다 — 어느 프로젝트인지는 요청이 정합니다. writeKey에 함수를 넘기세요.

const withReopt = reoptProxy({
  writeKey: (request) => lookupBrandWriteKey(request.headers.get("host")), // string | null | Promise<…>
  baseUrl: process.env.REOPT_BASE_URL!,
  proxy: true,
});

브랜드마다 API client가 다르다면 자격증명도 리졸버로 넘깁니다. 그러면 팩토리는 여전히 하나입니다.

// lib/reopt.ts — 요청 스코프 쪽도 같은 옵션 이름을 씁니다
export const { getReopt, getBootstrap } = createReopt({
  // 한 번의 조회로 세 값을 함께 받는 게 보통입니다 — writeKey는 쿠키 이름을,
  // clientId/clientSecret은 배치 인증을 담당합니다.
  writeKey: async () => (await lookupBrand())?.writeKey ?? null,
  credentials: async () => {
    const brand = await lookupBrand(); // 같은 요청 안에서는 캐시된 결과
    return brand && { clientId: brand.clientId, clientSecret: brand.clientSecret };
  },
  baseUrl: process.env.REOPT_BASE_URL!,
});

credentials가 있으면 clientId/clientSecret보다 우선합니다. 브랜드가 하나뿐이면 예전처럼 문자열 두 개를 넘기면 됩니다 — 둘 다 없으면 경고 한 번을 남기고 서버 이벤트가 꺼집니다.

엔진은 해석된 자격증명 기준으로 캐시됩니다. 팩토리 하나가 여러 프로젝트를 서빙해도 엔진은 요청당 하나가 아니라 프로젝트당 하나입니다. 이 API가 생기기 전에는 앱이 테넌트별로 팩토리를 만들어 모듈 스코프에 직접 메모이즈해야 했고, 그걸 요청마다 새로 만드는 실수가 배칭과 이 캐시를 동시에 날렸습니다.

  • createReopt의 리졸버는 요청 인자가 없습니다. headers()·cookies()를 직접 읽으면 되고, getProfileId같은 WeakMap으로 요청당 한 번만 실행됩니다 — 보통 DB 조회라서, 안 그러면 layout·라우트 핸들러가 페이지당 세 번씩 같은 조회를 합니다.
  • credentialswriteKey·getProfileId같은 WeakMap을 씁니다. 셋을 한 조회로 묶어 두면 요청당 DB 왕복은 한 번입니다. 절반만 채워 돌려주면(빈 secret 등) 자격증명이 없는 것으로 취급합니다 — 빈 secret으로 보내면 모든 배치가 인증에 실패하는데, 앱 쪽에서는 네트워크 문제와 구분되지 않기 때문입니다.
  • null을 반환하면 "이 요청은 어느 프로젝트에도 속하지 않는다"는 뜻입니다. getReopt()는 아무것도 보내지 않는 클라이언트를, getBootstrap()null을 돌려줍니다. 쿠키 이름조차 유도할 수 없으니 읽을 것도 보낼 것도 없습니다.
  • 리졸버가 던지면 그 요청만 추적되지 않습니다(경고 로그). 단, Next의 동적 렌더 신호는 그대로 다시 던집니다 — 삼키면 한 브랜드의 identity가 박힌 페이지가 모두에게 prerender됩니다.
  • proxy에서 리졸버가 null이거나 실패하면 시딩만 생략됩니다. /ingest/* rewrite는 그대로 진행합니다 — ingest는 배치의 헤더로 인증하고 rewrite 목적지는 baseUrl 오리진에 고정돼 있으므로, 여기서 브랜드를 조회하면 사이트가 보내는 모든 이벤트 배치 앞에 DB 조회가 하나씩 붙습니다.
  • 정적 문자열을 넘기면 proxy 핸들러는 완전히 동기로 돕니다. 리졸버를 쓸 때만 Promise<NextResponse>를 반환하고, Next의 proxy는 둘 다 받습니다.

matcher 주의: 대부분의 앱이 api/를 matcher에서 제외합니다. proxy: true/ingest/*를 쓰려면 그 경로가 matcher를 통과해야 합니다 — /ingest/api로 시작하지 않으므로 보통 제외 패턴에 걸리지 않지만, matcher를 화이트리스트(["/", "/about", ...])로 쓰는 앱이라면 "/ingest/:path*"를 명시적으로 추가해야 합니다. 빠뜨리면 proxy가 아예 실행되지 않아 브라우저의 /ingest/api/track 요청이 404로 떨어지고, SDK는 조용히 재시도만 반복합니다.

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico).*)",
    "/ingest/:path*", // 화이트리스트 matcher를 쓴다면 이 줄이 필수입니다
  ],
};

4. 서버 에러를 $exception으로

// instrumentation.ts
import { createOnRequestError } from "@reopt-ai/data-sdk-server";

export const onRequestError = createOnRequestError({
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  clientId: process.env.REOPT_CLIENT_ID!,
  clientSecret: process.env.REOPT_CLIENT_SECRET!,
  baseUrl: process.env.REOPT_BASE_URL!,
});

nextjs_route_path / nextjs_router_kind / nextjs_route_type / nextjs_render_source / nextjs_revalidate_reason / nextjs_error_digest를 붙입니다. 에러 추적 도구와 겹치지만, 제품 가치는 에러율을 퍼널·세그먼트와 같은 축에서 볼 수 있다는 데 있습니다.

브라우저 SDK와 같은 $exception_list(구조화된 cause 체인)도 함께 실립니다 — mechanism.typenextjs_onRequestError, handledfalse입니다. 프레임의 in_appnode_modules/node: 내장 모듈을 제외한 것이라, 스택 패널이 기본으로 여는 줄이 실제로 고칠 수 있는 코드입니다. 파서는 브라우저 SDK와 같은 구현을 씁니다 — 같은 모양의 스택이 서버와 클라이언트에서 다른 이슈로 묶이면 안 되니까요. 필드 표와 예시 JSON은 브라우저 SDK README에 있습니다.

beforeCapture로 속성을 병합하거나({...} 반환) 캡처를 취소할 수 있습니다(false 반환). 훅이 던지면 캡처를 건너뜁니다 — 훅이 무엇을 가려야 했는지 모르는 채 보내는 것보다 안 보내는 편이 안전합니다.

한 배포가 여러 프로젝트를 서빙할 때

브랜드마다 커스텀 도메인을 쓰는 프론트엔드는 요청마다 자격이 다릅니다. resolve로 해석하세요:

// instrumentation.ts
export const onRequestError = createOnRequestError({
  baseUrl: process.env.REOPT_BASE_URL!,
  resolve: async (request) => {
    const host = readHeader(request.headers, "host");
    const brand = host ? await findBrandByDomain(host) : null;
    if (!brand) return null; // 이 요청은 어느 프로젝트의 것도 아님

    return {
      writeKey: brand.reoptWriteKey,
      clientId: brand.reoptClientId,
      clientSecret: brand.reoptClientSecret,
    };
  },
});

writeKey도 함께 돌려줘야 합니다. 자격만으로는 배치 인증만 되고 귀속이 안 됩니다 — 방문자의 신원이 담긴 쿠키 이름을 write key가 정하기 때문에, 브랜드 A의 쿠키를 B의 키로 읽으면 아무것도 못 찾습니다.

  • null 반환 → 캡처하지 않습니다. 브랜드 외 요청(마케팅 페이지, 헬스체크)이 정상적으로 여기 해당합니다.
  • 던지면 → 역시 캡처하지 않습니다. beforeCapture와 같은 읽기입니다: 답하지 못한 질문은 승낙이 아니고, 테넌트를 찍어서 엉뚱한 프로젝트에 이슈를 만드는 것이 안 만드는 것보다 나쁩니다.
  • resolve가 있으면 정적 옵션보다 우선합니다. 없으면 정적 옵션이 그대로 쓰입니다.

캐시하지 않습니다. createReopt의 resolver는 한 페이지가 클라이언트를 여러 번 요청할 수 있어 요청당 1회로 캐시하지만, 이건 에러당 1회라 이미 드물고, 캐시를 걸면 그 캐시를 정당화한 요청보다 오래 삽니다.

4-1. 소스맵 업로드 — @reopt-ai/data-cli

프로덕션 스택은 minify된 청크 이름만 말합니다. 소스맵을 올려 두면 서버가 그것을 원본 위치로 되돌립니다. 업로드는 이 패키지가 아니라 별도 공개 CLI @reopt-ai/data-cli(reopt-data)가 담당합니다 — 빌드 파이프라인 도구를 런타임 SDK에 실어 보낼 이유가 없어서입니다.

pnpm add -D @reopt-ai/data-cli
reopt-data sourcemap inject --dir .next/static          # 선택: URL이 바뀌어도 맵이 해석되게
reopt-data sourcemap upload --dir .next/static \
  --url-prefix https://app.example.com/_next/static \
  --project-id <id> --release "$VERCEL_GIT_COMMIT_SHA" --delete-after-upload

한 배포가 여러 프로젝트를 서빙하면(symbolHostApp, 위 3·4절) 호스트 앱 단위로 경로 기준 한 번만 올립니다:

reopt-data sourcemap upload --dir .next/static --path-prefix /_next/static \
  --host-app <host app> --organization-id <org id> --release "$VERCEL_GIT_COMMIT_SHA"

타깃 세 가지(프로젝트 / 호스트 앱 / 플랫폼)·자격 증명·--dry-run·종료 코드는 그 패키지의 README가 canonical입니다. 이 패키지의 예전 reopt-data bin(upload-sourcemaps / inject-chunk-ids)은 0.5.0에서 제거됐고, 같은 철자가 새 CLI에서 그대로 동작합니다.

5. 방문자의 device id만 필요할 때

서버에서 확정한 전환을 나중에 보내는 앱 — 아웃박스에 적어 두고 크론이 배치로 포워딩하는 구조 — 은 요청 시점에 "이 전환이 누구 것인지"만 기록하면 됩니다.

import { readDeviceId } from "@reopt-ai/data-sdk-server";
import { cookies } from "next/headers";

const deviceId = readDeviceId(await cookies(), { writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY! });
await db.analyticsOutbox.create({ data: { name: "form.submitted", deviceId, ... } });

나중에 포워더가 그 값을 이벤트에 실어 보내면(deviceId 필드, 계약 0.6+) ingest가 행 단위로 그 방문자의 활성 세션에 붙입니다. 한 배치에 여러 방문자가 섞여도 됩니다 — 배치를 쪼갤 필요가 없으므로 accepted + duplicates + rejected === sent 불변식이 유지됩니다.

cookies() 결과, NextRequest.cookies, raw Cookie: 헤더 문자열을 모두 받습니다. 읽을 게 없으면 null을 반환하고 throw하지 않습니다 — 분석 때문에 결제가 실패하면 안 되니까요. 쿠키는 형제 서브도메인이 심을 수 있는 입력이므로 값은 검증을 거쳐 나옵니다.

쿠키 이름을 직접 조립하지 마세요. 이름은 writeKey에서 유도되며, 그 규칙이 두 저장소에 각각 적히면 조용히 어긋납니다 — 한쪽이 쓰고 다른 쪽이 못 찾아서 모든 방문자가 신규로 보입니다.

6. 순수 Node

import { createReoptNode } from "@reopt-ai/data-sdk-server/node";

const reopt = createReoptNode({
  clientId: process.env.REOPT_CLIENT_ID!,
  clientSecret: process.env.REOPT_CLIENT_SECRET!,
  baseUrl: process.env.REOPT_BASE_URL!,
});

await reopt.trackAndFlush("nightly_report_generated", { rows: 12_400 });
await reopt.close(); // 스크립트 종료 전 필수 — 타이머를 멈추고 큐를 비웁니다

Next.js 앱 안에서는 createReopt()를 쓰세요. 이 클라이언트는 읽을 요청이 없어서 이벤트를 방문자에게 묶지 못합니다.

세션은 어떻게 이어지나

서버 이벤트가 방문자의 device를 들고 오면 — 요청 헤더(getReopt()가 쿠키에서 읽어 자동으로 붙입니다)든, 이벤트 자신의 deviceId 필드(포워더)든 — ingest가 그 device의 활성 세션에 합류시킵니다. 활성 세션이 없으면(몇 시간 뒤 도착한 웹훅 등) 세션 없이 적재하고 새로 만들지 않습니다 — pageview 0짜리 유령 세션이 이탈률을 오염시키기 때문입니다.

행 단위 deviceId가 요청 헤더보다 우선합니다. 브라우저 자격증명(writeKey)으로 보낸 요청에서는 이 필드가 무시됩니다 — 거기선 헤더가 SDK 자신의 쿠키 값이고 body는 페이지가 쓴 것이라, body를 믿으면 아무 페이지나 원하는 device로 이벤트를 귀속시킬 수 있습니다.

브라우저가 보낸 reopt-session-id신뢰하지 않습니다. 누구나 임의 값을 넣을 수 있어, 그대로 믿으면 남의 세션에 이벤트를 주입할 수 있습니다. 세션은 항상 device에서 유도합니다.

개발

pnpm --filter @reopt-ai/data-sdk-server test        # 단위 (tsx --test)
pnpm test:e2e:sdk                                   # 격리된 전체 pipeline (vitest)
pnpm --filter @reopt-ai/data-sdk-server build       # tsdown + dist 검증

buildscripts/check-dist-guards.mjs로 산출물을 검사합니다: 서버 진입점이 server-only를 실제로 import하는지, dist 어디에도 DOM 참조가 없는지. 소스에서는 성립하다가 번들에서 조용히 깨지는 종류의 성질이라 빌드에 묶어 뒀습니다.