diff --git a/lib/proxy-agent.ts b/lib/proxy-agent.ts new file mode 100644 index 0000000..81563d3 --- /dev/null +++ b/lib/proxy-agent.ts @@ -0,0 +1,66 @@ +import { ProxyAgent } from 'undici' + +let proxyUrl: string | undefined +let proxyAgent: ProxyAgent | undefined +let noProxyPatterns: string[] = [] + +function refreshConfig() { + proxyUrl = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + + const raw = process.env.NO_PROXY || process.env.no_proxy || '' + noProxyPatterns = raw + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + + if (proxyUrl) { + proxyAgent = new ProxyAgent(proxyUrl, { + connect: { + rejectUnauthorized: false, + }, + }) + } else { + proxyAgent = undefined + } +} + +function matchesNoProxy(hostname: string): boolean { + if (noProxyPatterns.length === 0) { + return false + } + const h = hostname.toLowerCase() + return noProxyPatterns.some((pattern) => { + if (pattern === '*') { + return true + } + if (pattern.startsWith('.')) { + return h === pattern.slice(1) || h.endsWith(pattern) + } + if (pattern.includes('*')) { + const regex = new RegExp( + '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', + ) + return regex.test(h) + } + return h === pattern || h.endsWith('.' + pattern) + }) +} + +export function getProxyDispatcher(url: string) { + refreshConfig() + + if (!proxyUrl) { + return undefined + } + + const hostname = new URL(url).hostname + if (matchesNoProxy(hostname)) { + return undefined + } + + return proxyAgent +} diff --git a/package.json b/package.json index bca9f52..f3a6a8c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "remeda": "^2.33.6", "ts-pattern": "^5.9.0", "typescript": "^5.9.3", + "undici": "^8.3.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d5f6a4..7f855f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + undici: + specifier: ^8.3.0 + version: 8.3.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -5551,6 +5554,10 @@ packages: resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} engines: {node: '>=20.18.1'} + undici@8.3.0: + resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -12692,6 +12699,8 @@ snapshots: undici@7.24.6: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -12846,7 +12855,7 @@ snapshots: vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.27.3 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.8 diff --git a/routes/[...path].ts b/routes/[...path].ts index 1806232..c1e2530 100644 --- a/routes/[...path].ts +++ b/routes/[...path].ts @@ -1,7 +1,19 @@ +import { fetch as undiciFetch } from 'undici' import { env } from '~/lib/env' import { logger } from '~/lib/logger' +import { getProxyDispatcher } from '~/lib/proxy-agent' export default defineEventHandler(async (event) => { - logger.debug('proxying unknown path', event.path, 'to', env.DEFAULT_ACTIONS_RESULTS_URL) - return proxyRequest(event, `${env.DEFAULT_ACTIONS_RESULTS_URL}${event.path}`) + const upstream = env.DEFAULT_ACTIONS_RESULTS_URL + const targetUrl = `${upstream}${event.path}` + + logger.debug('proxying unknown path', event.path, 'to', upstream) + + const fetchOptions: Record = {} + const dispatcher = getProxyDispatcher(targetUrl) + if (dispatcher) { + fetchOptions.dispatcher = dispatcher + } + + return proxyRequest(event, targetUrl, { fetch: undiciFetch, fetchOptions }) }) diff --git a/routes/twirp/[...path].ts b/routes/twirp/[...path].ts new file mode 100644 index 0000000..30084d5 --- /dev/null +++ b/routes/twirp/[...path].ts @@ -0,0 +1,58 @@ +import { fetch as undiciFetch } from 'undici' +import { env } from '~/lib/env' +import { logger } from '~/lib/logger' +import { getProxyDispatcher } from '~/lib/proxy-agent' + +const SKIP_HEADERS = new Set([ + 'content-length', + 'host', + 'connection', + 'keep-alive', + 'transfer-encoding', +]) + +export default defineEventHandler(async (event) => { + const upstream = env.DEFAULT_ACTIONS_RESULTS_URL + const targetUrl = `${upstream}${event.path}` + + try { + logger.debug('proxying twirp path', event.path, 'to', upstream) + + const dispatcher = getProxyDispatcher(targetUrl) + + const headers: Record = {} + for (const [key, value] of Object.entries(getRequestHeaders(event))) { + if (!SKIP_HEADERS.has(key.toLowerCase()) && typeof value === 'string') { + headers[key] = value + } + } + + const body = await readRawBody(event, false).catch(() => undefined) + const res = await undiciFetch(targetUrl, { + method: event.method, + headers, + body: body || undefined, + signal: AbortSignal.timeout(5000), + ...(dispatcher ? { dispatcher } : {}), + }) + + setResponseStatus(event, res.status, res.statusText) + for (const [key, value] of res.headers) { + setResponseHeader(event, key, value) + } + return res.body + } catch (err) { + logger.warn( + 'twirp proxy failed for', + event.path, + '- returning stub 200', + err, + ) + + const contentType = + getRequestHeader(event, 'content-type') || 'application/protobuf' + setResponseHeader(event, 'content-type', contentType) + setResponseStatus(event, 200) + return '' + } +}) diff --git a/tests/twirp-catchall.test.ts b/tests/twirp-catchall.test.ts new file mode 100644 index 0000000..052f56c --- /dev/null +++ b/tests/twirp-catchall.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest' + +describe('twirp catch-all route', () => { + test( + 'unmatched twirp paths should not return 404', + { timeout: 10_000 }, + async () => { + const res = await fetch( + `${process.env.API_BASE_URL}/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }, + ) + expect(res.status).toBe(200) + }, + ) +})