BFFを作成します

This commit is contained in:
ry.yamafuji 2025-12-18 21:34:26 +09:00
parent 1779a68eab
commit 46b8cfddf4
3 changed files with 308 additions and 0 deletions

68
src/bff/simpleBFF.php Normal file
View File

@ -0,0 +1,68 @@
<?php
/**
* 簡易BFFstream版
* APIサーバーへのリクエストを中継しつつ、dist以下の静的ファイルも返す
* 大容量100MB:非推奨
* 軽量な用途向け小規模サイトやAPI中継のみなど
*/
// ===== 設定 =====
$API_BASE_URL = getenv('API_BASE_URL'); // 例: https://xxxxx-uc.a.run.app
if (!$API_BASE_URL) {
http_response_code(500);
echo 'API_BASE_URL is not set';
exit;
}
// ===== リクエスト情報 =====
$method = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI']; // クエリ含む
$path = parse_url($uri, PHP_URL_PATH);
// /api/* だけ中継
if (strpos($path, '/api') === 0) {
$upstreamPath = substr($uri, 4); // /api/foo?x=1 -> /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');

78
src/bff/smallBFF.php Normal file
View File

@ -0,0 +1,78 @@
<?php
// =========================
// 設定
// =========================
$API_BASE_URL = getenv('API_BASE_URL'); // 例: https://xxxxx-uc.a.run.app
if (!$API_BASE_URL) {
http_response_code(500);
echo 'API_BASE_URL is not set';
exit;
}
// =========================
// リクエスト情報
// =========================
$method = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI']; // クエリ含む
$path = parse_url($uri, PHP_URL_PATH);
// =========================
// /api/* → Cloud Runへ中継
// =========================
if (strpos($path, '/api') === 0) {
// /api/foo?x=1 → /foo?x=1
$upstreamPath = substr($uri, 4);
if ($upstreamPath === '') {
$upstreamPath = '/';
}
$url = rtrim($API_BASE_URL, '/') . $upstreamPath;
// ヘッダそのまま転送(最低限)
$headers = [];
foreach (getallheaders() as $k => $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');

162
src/bff/streamBFF.php Normal file
View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
// ===== 設定 =====
$API_BASE_URL = getenv('API_BASE_URL'); // 例: https://xxxxx-uc.a.run.app
if (!$API_BASE_URL) {
http_response_code(response_code: 500);
header('Content-Type: text/plain; charset=utf-8');
echo "API_BASE_URL is not set\n";
exit;
}
// distのパスこのindex.phpが public/ にある想定)
$DIST_DIR = realpath(__DIR__ . '/../dist') ?: (__DIR__ . '/../dist');
// ===== ユーティリティ =====
function send_text(int $code, string $text): void {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $text;
exit;
}
function is_hop_by_hop(string $headerNameLower): bool {
static $hop = [
'transfer-encoding' => 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 fallbackindex.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");