From 8bf5f395a54cac14e6ccf3fac8a5fb3129b4f3dd Mon Sep 17 00:00:00 2001 From: yinheli Date: Sat, 6 Jun 2026 15:19:24 +0800 Subject: [PATCH 1/3] fix: add twirp catch-all route to proxy unmatched paths Unmatched Twirp paths (e.g., ArtifactService) currently return 404 because Nitro marks the /twirp/ segment as handled without a catch-all. Add routes/twirp/[...path].ts to proxy them to DEFAULT_ACTIONS_RESULTS_URL, matching the pattern of the root catch-all in routes/[...path].ts. Fixes #237 --- routes/twirp/[...path].ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 routes/twirp/[...path].ts diff --git a/routes/twirp/[...path].ts b/routes/twirp/[...path].ts new file mode 100644 index 0000000..204e5a1 --- /dev/null +++ b/routes/twirp/[...path].ts @@ -0,0 +1,7 @@ +import { env } from '~/lib/env' +import { logger } from '~/lib/logger' + +export default defineEventHandler(async (event) => { + logger.debug('proxying unknown twirp path', event.path, 'to', env.DEFAULT_ACTIONS_RESULTS_URL) + return proxyRequest(event, `${env.DEFAULT_ACTIONS_RESULTS_URL}${event.path}`) +}) From ba809ea119ebe6d18e6c3725026c3a20355f51b0 Mon Sep 17 00:00:00 2001 From: yinheli Date: Sat, 6 Jun 2026 15:46:31 +0800 Subject: [PATCH 2/3] fix: enable proxy support for twirp catch-all via HTTP_PROXY env Node.js/undici does not respect HTTP_PROXY env vars by default. Add lib/proxy-agent.ts to create an undici ProxyAgent from env vars and inject it via fetchOptions.dispatcher. Also add undici as direct dep. --- lib/proxy-agent.ts | 66 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 11 ++++++- routes/[...path].ts | 16 ++++++++-- routes/twirp/[...path].ts | 55 ++++++++++++++++++++++++++++++-- 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 lib/proxy-agent.ts 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 index 204e5a1..30084d5 100644 --- a/routes/twirp/[...path].ts +++ b/routes/twirp/[...path].ts @@ -1,7 +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) => { - logger.debug('proxying unknown twirp 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}` + + 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 '' + } }) From 84a848de5986f6f6d19b751c532e529f0cfaf94e Mon Sep 17 00:00:00 2001 From: yinheli Date: Sun, 7 Jun 2026 03:13:38 +0800 Subject: [PATCH 3/3] test: add regression test for twirp catch-all route Verifies that unmatched /twirp/* paths (e.g. ArtifactService) are proxied instead of returning 404. --- tests/twirp-catchall.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/twirp-catchall.test.ts 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) + }, + ) +})