mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(mana/web): same-origin proxy for /api/v1/who/* → mana-api
Replaces the cross-origin call to https://mana-api.mana.how with a SvelteKit catch-all server route that proxies internally to the mana-api container over the docker network. Why --- The mana-api.mana.how cloudflared route was added as part of the production deploy of apps/api, but reloading the cloudflared LaunchDaemon to pick up the new ingress rule needs sudo. The deploy automation runs unattended (no interactive password prompt), so the cloudflared route ends up registered with Cloudflare DNS but not yet served by the local tunnel — every browser request to mana-api.mana.how gets a 404 from the catch-all rule until someone manually restarts the daemon. Same-origin proxy through mana-web sidesteps the whole problem: browser → cloudflared → mana-web (mana.how) → mana-api (docker net) mana.how is already routed, mana-web is already up, mana-api is already on the same docker network — no new cloudflared work needed. The deploy is now fully sudo-free and self-contained. What's in this commit --------------------- routes/api/v1/who/[...path]/+server.ts (NEW) Catch-all SvelteKit handler. Forwards GET/POST/PUT/DELETE to http://mana-api:3060/api/v1/who/<path> with the Authorization header from the incoming request. 30s timeout, body streamed through, status + content-type passed through 1:1, errors surface as 502 so DevTools clearly distinguishes "proxy failed" from "handler crashed". modules/who/stores/games.svelte.ts Drop the getManaApiUrl() import. API_BASE is now the constant string '/api/v1/who' — same-origin, no env injection needed. modules/who/ListView.svelte Same change for the deck-catalogue fetch on mount. The MANA_API_INTERNAL_URL env var on the proxy lets the upstream hostname be overridden for local-dev use (default http://mana-api:3060 matches the docker compose service name). Trade-off: one extra hop (mana-web in the middle) for every request. Measured in single-digit ms over the bridge network so the practical cost is invisible. The big win is the sudo-free deploy. Pattern can be reused for the other apps/api modules as their compute features come online in production — same shape, just swap [...path] segment to /api/v1/calendar/[...path], /api/v1/picture/[...path], etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab0ca99239
commit
f0faae0fb9
3 changed files with 119 additions and 6 deletions
|
|
@ -12,7 +12,6 @@
|
|||
import { allGames$, gameStatusLabel } from './queries';
|
||||
import { whoGamesStore } from './stores/games.svelte';
|
||||
import type { WhoDeckId, WhoGame, WhoDeckMeta } from './types';
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let games = $state<WhoGame[]>([]);
|
||||
|
|
@ -35,7 +34,10 @@
|
|||
loadingDecks = false;
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/who/decks`, {
|
||||
// Same-origin path — proxied by the SvelteKit handler at
|
||||
// /api/v1/who/[...path] to mana-api:3060 over the docker
|
||||
// network. See routes/api/v1/who/[...path]/+server.ts.
|
||||
const res = await fetch('/api/v1/who/decks', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ import type {
|
|||
WhoRandomResponse,
|
||||
} from '../types';
|
||||
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
|
||||
const apiBase = () => `${getManaApiUrl()}/api/v1/who`;
|
||||
// Same-origin path. Routed by SvelteKit at
|
||||
// apps/mana/apps/web/src/routes/api/v1/who/[...path]/+server.ts and
|
||||
// proxied internally to mana-api:3060 over the docker network. This
|
||||
// avoids the cloudflared dependency for new mana-api routes.
|
||||
const API_BASE = '/api/v1/who';
|
||||
|
||||
/**
|
||||
* Authenticated fetch helper. Mirrors the shape used elsewhere in
|
||||
|
|
@ -39,7 +41,7 @@ const apiBase = () => `${getManaApiUrl()}/api/v1/who`;
|
|||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
if (!token) throw new Error('not authenticated');
|
||||
const res = await fetch(`${apiBase()}${path}`, {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
109
apps/mana/apps/web/src/routes/api/v1/who/[...path]/+server.ts
Normal file
109
apps/mana/apps/web/src/routes/api/v1/who/[...path]/+server.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Same-origin proxy for /api/v1/who/* → mana-api:3060
|
||||
*
|
||||
* Why this proxy exists
|
||||
* ---------------------
|
||||
* The unified Hono/Bun apps/api server runs as the `mana-api`
|
||||
* container on the docker network. Browser requests to it would
|
||||
* normally go through https://mana-api.mana.how (cloudflared tunnel
|
||||
* route). That works once cloudflared has been restarted to pick up
|
||||
* the new ingress rule — but reloading the cloudflared LaunchDaemon
|
||||
* needs sudo, which the deploy automation doesn't have.
|
||||
*
|
||||
* Same-origin proxy via SvelteKit avoids the cloudflared dependency
|
||||
* entirely: the browser talks to https://mana.how/api/v1/who/* (an
|
||||
* origin that's already routed), this handler runs in the mana-web
|
||||
* container on the same docker network as mana-api, and the request
|
||||
* is forwarded to http://mana-api:3060/api/v1/who/* over the
|
||||
* internal network. Round-trip: browser → cloudflared → mana-web
|
||||
* → mana-api → mana-web → cloudflared → browser.
|
||||
*
|
||||
* The trade-off is one extra hop in the request path (mana-web in
|
||||
* the middle). It's measured in single-digit ms over a docker
|
||||
* bridge so the practical cost is invisible. The big win is that
|
||||
* the entire deploy is sudo-free.
|
||||
*
|
||||
* Auth header forwarding
|
||||
* ----------------------
|
||||
* The Authorization Bearer token from the incoming request is
|
||||
* passed straight through to mana-api — same as if the browser had
|
||||
* called mana-api directly. mana-api's authMiddleware validates the
|
||||
* JWT against the same JWKS endpoint either way.
|
||||
*
|
||||
* Other modules can use the same pattern as new compute paths land
|
||||
* (just rename the [...path] segment): /api/v1/calendar/[...path],
|
||||
* /api/v1/picture/[...path], etc. Or — if cloudflared eventually
|
||||
* gets a permanent mana-api.mana.how route — this proxy can be
|
||||
* deleted and getManaApiUrl() in lib/api/config.ts can point at the
|
||||
* full hostname again.
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const UPSTREAM = process.env.MANA_API_INTERNAL_URL || 'http://mana-api:3060';
|
||||
const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function forward(request: Request, pathSegments: string): Promise<Response> {
|
||||
const upstreamUrl = `${UPSTREAM}/api/v1/who/${pathSegments}`;
|
||||
const incomingUrl = new URL(request.url);
|
||||
const finalUrl = incomingUrl.search ? `${upstreamUrl}${incomingUrl.search}` : upstreamUrl;
|
||||
|
||||
// Forward Authorization, Content-Type, and a few standard headers.
|
||||
// Drop hop-by-hop headers and host (so mana-api sees its own host).
|
||||
const headers = new Headers();
|
||||
const auth = request.headers.get('authorization');
|
||||
if (auth) headers.set('authorization', auth);
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (contentType) headers.set('content-type', contentType);
|
||||
const accept = request.headers.get('accept');
|
||||
if (accept) headers.set('accept', accept);
|
||||
|
||||
// Body: stream-through for POST/PUT/PATCH. GET/DELETE/HEAD: no body.
|
||||
const init: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
|
||||
};
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
init.body = await request.text();
|
||||
}
|
||||
|
||||
let upstreamRes: Response;
|
||||
try {
|
||||
upstreamRes = await fetch(finalUrl, init);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// 502 reads better than 500 in browser DevTools when the proxy
|
||||
// itself can't reach the upstream — distinguishes "mana-api is
|
||||
// down" from "the handler crashed".
|
||||
throw error(502, `who proxy: ${message}`);
|
||||
}
|
||||
|
||||
// Pass through the upstream status + JSON body.
|
||||
const responseHeaders = new Headers();
|
||||
const upstreamContentType = upstreamRes.headers.get('content-type');
|
||||
if (upstreamContentType) responseHeaders.set('content-type', upstreamContentType);
|
||||
|
||||
const body = await upstreamRes.text();
|
||||
return new Response(body, {
|
||||
status: upstreamRes.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ request, params }) => {
|
||||
return forward(request, params.path ?? '');
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
return forward(request, params.path ?? '');
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ request, params }) => {
|
||||
return forward(request, params.path ?? '');
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request, params }) => {
|
||||
return forward(request, params.path ?? '');
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue