diff --git a/packages/core/src/web-urls.ts b/packages/core/src/web-urls.ts index a7df166..a238271 100644 --- a/packages/core/src/web-urls.ts +++ b/packages/core/src/web-urls.ts @@ -1,10 +1,13 @@ // packages/core/src/web-urls.ts // 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다. // -// 한 도메인 아래 두 앱이 있다. -// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락 -// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드 -// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app 요청을 웹앱으로 보낸다. +// 한 도메인 아래 세 서비스가 있다. +// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락 +// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드 +// /api, /health API 서버(apps/api-server) — 관리자 인증, STT 중계 +// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app·/api 요청을 도메인 원본 +// (Cloudflare Tunnel kd-nas → NAS)으로 흘려보내고, 나머지는 Pages로 보낸다. +// 터널 ingress: d3ro.chanpaca.net path ^/app → NAS 3002, 그 밖 → NAS 5050. // Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다. import type { PaidPlanTier } from './plan-catalog' @@ -16,6 +19,9 @@ export const WEB_APP_BASE_PATH = '/app' export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}` +/** API 서버(apps/api-server)가 받는 경로. 관리자 로그인·stt-proxy가 이 도메인으로 부른다. */ +export const API_PATH_PREFIXES = ['/api', '/health'] as const + export const SITE_URLS = { home: `${PUBLIC_SITE_ORIGIN}/`, download: `${PUBLIC_SITE_ORIGIN}/#download`, diff --git a/server/cloudflare-site-bridge/src/index.ts b/server/cloudflare-site-bridge/src/index.ts index e73f4a3..84c794f 100644 --- a/server/cloudflare-site-bridge/src/index.ts +++ b/server/cloudflare-site-bridge/src/index.ts @@ -1,75 +1,46 @@ // server/cloudflare-site-bridge/src/index.ts -// d3ro.chanpaca.net 한 도메인 아래 두 앱을 잇는 브리지. +// d3ro.chanpaca.net 한 도메인 아래 세 서비스를 잇는 브리지. // -// /app, /app/* → 웹앱(apps/web, NAS 컨테이너) — WEB_APP_ORIGIN(터널 호스트) -// 그 밖 → 랜딩 사이트(site/, Cloudflare Pages) +// /app, /app/* → 도메인 원본(Cloudflare Tunnel kd-nas) → NAS 웹앱(3002) +// /api/*, /health → 도메인 원본(Cloudflare Tunnel kd-nas) → NAS API 서버(5050) +// 그 밖 → 랜딩 사이트(site/, Cloudflare Pages) // -// 경로 규칙의 정본은 packages/core/src/web-urls.ts 의 WEB_APP_BASE_PATH 다. -// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안 -// 이 워커가 도메인을 살린다. +// 경로 규칙의 정본은 packages/core/src/web-urls.ts 다. 터널 ingress가 path로 웹앱과 API를 나눈다. +// 워커 안에서 같은 요청을 그대로 fetch 하면 워커를 다시 거치지 않고 도메인 원본(터널)으로 간다. +// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구해 d3ro 레코드(터널)와 충돌하므로, 이 워커가 +// 랜딩을 Pages에서 가져온다. -import { WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls' +import { API_PATH_PREFIXES, WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls' const PAGES_ORIGIN = 'https://d3ro.pages.dev' +const ORIGIN_PATH_PREFIXES: readonly string[] = [WEB_APP_BASE_PATH, ...API_PATH_PREFIXES] -interface Env { - /** 웹앱 컨테이너를 노출한 터널 호스트(예: https://d3ro-app.chanpaca.net). 비어 있으면 /app 은 503. */ - WEB_APP_ORIGIN?: string +function servedByOrigin(pathname: string): boolean { + return ORIGIN_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)) } -function isWebAppPath(pathname: string): boolean { - return pathname === WEB_APP_BASE_PATH || pathname.startsWith(`${WEB_APP_BASE_PATH}/`) -} - -async function proxy(request: Request, origin: string, extraHeaders: Record = {}): Promise { - const incoming = new URL(request.url) - const upstream = new URL(origin) - const target = new URL(incoming.pathname + incoming.search, upstream) +async function fromPages(request: Request): Promise { + const target = new URL(request.url) + target.protocol = 'https:' + target.hostname = new URL(PAGES_ORIGIN).hostname + target.port = '' const headers = new Headers(request.headers) headers.delete('host') - for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value) const hasBody = request.method !== 'GET' && request.method !== 'HEAD' - const response = await fetch(target.toString(), { + return fetch(target.toString(), { method: request.method, headers, body: hasBody ? request.body : undefined, redirect: 'manual', }) - - // 원본 호스트로 나가는 리다이렉트를 공개 도메인으로 되돌린다. - const location = response.headers.get('location') - if (location) { - const resolved = new URL(location, target) - if (resolved.host === upstream.host) { - resolved.protocol = incoming.protocol - resolved.host = incoming.host - const rewritten = new Response(response.body, response) - rewritten.headers.set('location', resolved.toString()) - return rewritten - } - } - return response } export default { - async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url) - - if (isWebAppPath(url.pathname)) { - if (!env.WEB_APP_ORIGIN) { - return new Response('Web app is not available yet.', { - status: 503, - headers: { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '3600' }, - }) - } - return proxy(request, env.WEB_APP_ORIGIN, { - 'x-forwarded-host': url.host, - 'x-forwarded-proto': url.protocol.replace(':', ''), - }) - } - - return proxy(request, PAGES_ORIGIN) + async fetch(request: Request): Promise { + const { pathname } = new URL(request.url) + if (servedByOrigin(pathname)) return fetch(request) + return fromPages(request) }, } diff --git a/server/cloudflare-site-bridge/wrangler.toml b/server/cloudflare-site-bridge/wrangler.toml index 103f431..9b9df39 100644 --- a/server/cloudflare-site-bridge/wrangler.toml +++ b/server/cloudflare-site-bridge/wrangler.toml @@ -18,8 +18,3 @@ compatibility_date = "2024-04-01" routes = [ { pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" } ] - -# 웹앱(apps/web) 컨테이너를 노출한 Cloudflare Tunnel 호스트. 터널 공개 호스트를 추가한 뒤 채운다. -# 비어 있으면 /app/* 는 503 을 돌려준다(랜딩으로 떨어뜨리지 않는다). -[vars] -WEB_APP_ORIGIN = "" diff --git a/server/supabase/functions/_shared/core-contract.generated.ts b/server/supabase/functions/_shared/core-contract.generated.ts index f302dfa..60babc9 100644 --- a/server/supabase/functions/_shared/core-contract.generated.ts +++ b/server/supabase/functions/_shared/core-contract.generated.ts @@ -93,10 +93,13 @@ export const PLAN_QUOTA: Readonly