From 46b8cfddf4f2c75fd549b3482603364c94d33188 Mon Sep 17 00:00:00 2001 From: "ry.yamafuji" Date: Thu, 18 Dec 2025 21:34:26 +0900 Subject: [PATCH] =?UTF-8?q?BFF=E3=82=92=E4=BD=9C=E6=88=90=E3=81=97?= =?UTF-8?q?=E3=81=BE=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bff/simpleBFF.php | 68 ++++++++++++++++++ src/bff/smallBFF.php | 78 ++++++++++++++++++++ src/bff/streamBFF.php | 162 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 src/bff/simpleBFF.php create mode 100644 src/bff/smallBFF.php create mode 100644 src/bff/streamBFF.php diff --git a/src/bff/simpleBFF.php b/src/bff/simpleBFF.php new file mode 100644 index 0000000..eca8e00 --- /dev/null +++ b/src/bff/simpleBFF.php @@ -0,0 +1,68 @@ + /foo?x=1 + if ($upstreamPath === '') $upstreamPath = '/'; + + $url = rtrim($API_BASE_URL, '/') . $upstreamPath; + + // ヘッダそのまま(最低限) + $headers = []; + foreach (getallheaders() as $k => $v) { + if (strtolower($k) === 'host') continue; + $headers[] = "$k: $v"; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'content' => in_array($method, ['POST', 'PUT', 'PATCH']) + ? file_get_contents('php://input') + : null, + 'ignore_errors' => true, // 4xx/5xxも受け取る + ] + ]); + + $response = file_get_contents($url, false, $context); + + // ステータスコードをそのまま返す + if (isset($http_response_header[0])) { + preg_match('#HTTP/\d\.\d\s+(\d+)#', $http_response_header[0], $m); + if (!empty($m[1])) { + http_response_code((int)$m[1]); + } + } + + // ヘッダも最低限返す + foreach ($http_response_header as $h) { + if (stripos($h, 'Transfer-Encoding:') === 0) continue; + if (stripos($h, 'Connection:') === 0) continue; + header($h, false); + } + + echo $response; + exit; +} + +// ===== それ以外は静的(例: dist/index.html) ===== +readfile(__DIR__ . '/dist/index.html'); diff --git a/src/bff/smallBFF.php b/src/bff/smallBFF.php new file mode 100644 index 0000000..93a29d7 --- /dev/null +++ b/src/bff/smallBFF.php @@ -0,0 +1,78 @@ + $v) { + if (strtolower($k) === 'host') continue; + $headers[] = "$k: $v"; + } + + // body(非ストリーミング:全部読む) + $body = null; + if (!in_array($method, ['GET', 'HEAD'])) { + $body = file_get_contents('php://input'); + } + + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'ignore_errors' => true, // 4xx/5xxも取得 + ] + ]); + + // ===== 実行 ===== + $responseBody = file_get_contents($url, false, $context); + + // ===== ステータスコード ===== + if (isset($http_response_header[0])) { + if (preg_match('#HTTP/\d\.\d\s+(\d+)#', $http_response_header[0], $m)) { + http_response_code((int)$m[1]); + } + } + + // ===== レスポンスヘッダ ===== + foreach ($http_response_header as $h) { + if (stripos($h, 'Transfer-Encoding:') === 0) continue; + if (stripos($h, 'Connection:') === 0) continue; + header($h, false); + } + + echo $responseBody; + exit; +} + +// ========================= +// それ以外は静的(SPA想定) +// ========================= +readfile(__DIR__ . '/dist/index.html'); diff --git a/src/bff/streamBFF.php b/src/bff/streamBFF.php new file mode 100644 index 0000000..7e9ded7 --- /dev/null +++ b/src/bff/streamBFF.php @@ -0,0 +1,162 @@ + true, + 'connection' => true, + 'keep-alive' => true, + 'proxy-authenticate' => true, + 'proxy-authorization' => true, + 'te' => true, + 'trailers' => true, + 'upgrade' => true, + ]; + return isset($hop[$headerNameLower]); +} + +// ===== ルーティング ===== +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +// クエリ含むREQUEST_URIからパス部分だけ抽出 +$path = parse_url($uri, PHP_URL_PATH) ?? '/'; + +// -------- /api 配下はプロキシ -------- +if (preg_match('#^/api(?:/.*)?$#', $path)) { + $upstreamPath = preg_replace('#^/api#', '', $uri); // クエリ含めてそのまま + if ($upstreamPath === '') $upstreamPath = '/'; + + // URL結合(API_BASE_URL + upstreamPath) + $base = rtrim($API_BASE_URL, '/'); + $targetUrl = $base . $upstreamPath; + + // 受けたヘッダを基本そのまま転送(Hostは除外) + $headers = []; + if (function_exists('getallheaders')) { + foreach (getallheaders() as $k => $v) { + $lk = strtolower($k); + if ($lk === 'host') continue; + if ($lk === 'content-length') continue; // ストリーム時は基本付けない + $headers[] = $k . ': ' . $v; + } + } + + // 転送用のcURL + $ch = curl_init($targetUrl); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + // レスポンスをストリームでクライアントに返す + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $headerLine) { + $len = strlen($headerLine); + $headerLineTrim = trim($headerLine); + if ($headerLineTrim === '') return $len; + + // HTTP/1.1 200 OK みたいな行は無視(ステータスは後でセット) + if (stripos($headerLineTrim, 'HTTP/') === 0) return $len; + + $parts = explode(':', $headerLineTrim, 2); + if (count($parts) !== 2) return $len; + $name = trim($parts[0]); + $value = trim($parts[1]); + + if (is_hop_by_hop(strtolower($name))) return $len; + header($name . ': ' . $value, false); + return $len; + }); + + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) { + echo $data; + return strlen($data); + }); + + // bodyはphp://inputをそのまま流す(GET/HEADは除外) + if (!in_array($method, ['GET', 'HEAD'], true)) { + $in = fopen('php://input', 'rb'); + if ($in === false) send_text(500, "Failed to open php://input\n"); + + // Content-Lengthが分かるなら設定(無理なら未設定でOK) + $contentLength = $_SERVER['CONTENT_LENGTH'] ?? null; + if ($contentLength !== null && ctype_digit((string)$contentLength)) { + curl_setopt($ch, CURLOPT_INFILESIZE, (int)$contentLength); + } + + curl_setopt($ch, CURLOPT_UPLOAD, true); + curl_setopt($ch, CURLOPT_INFILE, $in); + } + + // ステータスコードをクライアントへ反映 + $ok = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: 502; + + if ($ok === false) { + $err = curl_error($ch); + curl_close($ch); + http_response_code(502); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => 'Bad Gateway', 'detail' => $err], JSON_UNESCAPED_UNICODE); + exit; + } + + curl_close($ch); + http_response_code($status); + exit; +} + +// -------- 静的配信(dist) -------- +// dist内ファイルがあれば返す(パストラバーサル対策でrealpathで検証) +$rel = ltrim($path, '/'); +$maybe = $DIST_DIR . '/' . $rel; + +if ($rel !== '' && file_exists($maybe)) { + $real = realpath($maybe); + if ($real !== false && str_starts_with($real, $DIST_DIR) && is_file($real)) { + // 雑にMIME(必要なら拡張) + $ext = strtolower(pathinfo($real, PATHINFO_EXTENSION)); + $mime = match ($ext) { + 'js' => 'application/javascript; charset=utf-8', + 'css' => 'text/css; charset=utf-8', + 'html' => 'text/html; charset=utf-8', + 'json' => 'application/json; charset=utf-8', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'svg' => 'image/svg+xml', + default => 'application/octet-stream', + }; + header('Content-Type: ' . $mime); + readfile($real); + exit; + } +} + +// SPA fallback(index.html) +$index = $DIST_DIR . '/index.html'; +if (is_file($index)) { + header('Content-Type: text/html; charset=utf-8'); + readfile($index); + exit; +} + +send_text(404, "dist/index.html not found\n");