fix(opencode): send x-headroom-project header on all proxied requests (#2868)

## Description

The OpenCode transport plugin set `HEADROOM_PROJECT` as a shell env var
for child processes but never forwarded it as `x-headroom-project` on
the actual proxied HTTP requests. The proxy's `classify_project` only
attributes traffic via `x-headroom-project` header or `/p/<name>` URL
prefix — without the header, every OpenCode request was unattributed and
the Per-Project Savings dashboard showed `0 project(s)` permanently.

Fixes #2847.

## Root cause

`installHeadroomTransport` was called with only `{ proxyUrl, debug }`.
The `project` value was computed and used only in the `shell.env` hook
(for subprocess env injection), never threaded through to
`mergeFetchHeaders` or `headersForNodeRequest`.

## Changes Made

1. Add `project?: string` to `InstallOptions` and `TransportState`.
2. Resolve the project value once at plugin init (`pluginOptions.project
→ input.project.id → input.directory`) and pass it to
`installHeadroomTransport`.
3. Both header-building seams now set `x-headroom-project` when a
project is present:
   - `mergeFetchHeaders` (wrapped `fetch` path)
- `headersForNodeRequest` (wrapped `http.request` / `https.request`
path)
4. Reuse the resolved `project` in the `shell.env` hook (removes the
duplicate resolution that was there before).

## Changes

- `plugins/opencode/src/transport.ts` — `InstallOptions.project`,
`TransportState.project`; `mergeFetchHeaders`, `headersForNodeRequest`,
`routedNodeOptions`, `withRoutedFetchInput`, `installHeadroomTransport`
updated
- `plugins/opencode/src/plugin.ts` — resolve `project` once, pass it to
transport; reuse in `shell.env`
- `plugins/opencode/src/transport.test.ts` — 3 new tests: project header
on fetch, project header on https.request, no header when project unset
- `headroom/providers/opencode/_dist/entry.opencode.js` — rebuilt with
`npm run build:standalone` to match source

## Testing

- [x] Unit tests pass
- [x] TypeScript typecheck passes
- [x] New regression tests added

### Test Output

```
cd plugins/opencode && npm test
# 17 passed (14 existing + 3 new)
```

TypeScript build also passes: `npm run typecheck` (no errors).

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring

## Real Behavior Proof

- Environment: OpenCode transport plugin test environment on the current
PR head.
- Exact command / steps: ran the plugin test suite and TypeScript
typecheck after rebuilding the standalone bundle.
- Observed result: all 17 tests passed, including project-header
coverage for fetch and Node HTTPS paths plus the unset-project control;
typechecking passed.
- Not tested: a live OpenCode session against a deployed Headroom proxy.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Signed-off-by: Radhakrishnan P <gingeekrishna@gmail.com>
Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
This commit is contained in:
Radhakrishnan Pachyappan 2026-08-18 08:51:24 +05:30 committed by GitHub
parent 2cae0f8eaf
commit eeb038bc0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 27 deletions

View file

@ -12487,6 +12487,7 @@ var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs"); var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url"; var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path"; var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROJECT_HEADER = "x-headroom-project";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL"; var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport"); var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() { function getState() {
@ -12635,7 +12636,7 @@ function requestUrl(input) {
} }
return new URL(String(input)); return new URL(String(input));
} }
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) { function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0); const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) { if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value)); new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@ -12647,9 +12648,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
if (originalPath) { if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath); headers.set(ORIGINAL_PATH_HEADER, originalPath);
} }
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers; return headers;
} }
function withRoutedFetchInput(input, init, proxy) { function withRoutedFetchInput(input, init, proxy, project) {
const upstream = requestUrl(input); const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) { if (!shouldRoute(upstream, proxy)) {
return [input, init]; return [input, init];
@ -12657,7 +12661,7 @@ function withRoutedFetchInput(input, init, proxy) {
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy); const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = { const nextInit = {
...init, ...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath) headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
}; };
if (input instanceof Request) { if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit]; return [new Request(nextUrl, input), nextInit];
@ -12703,12 +12707,15 @@ function urlFromRequestOptions(options) {
return void 0; return void 0;
} }
} }
function headersForNodeRequest(options, upstream, originalPath) { function headersForNodeRequest(options, upstream, originalPath, project) {
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin); headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) { if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath); headers.set(ORIGINAL_PATH_HEADER, originalPath);
} }
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host"); headers.delete("host");
const result = {}; const result = {};
headers.forEach((value, key) => { headers.forEach((value, key) => {
@ -12716,7 +12723,7 @@ function headersForNodeRequest(options, upstream, originalPath) {
}); });
return result; return result;
} }
function routedNodeOptions(parts, proxy) { function routedNodeOptions(parts, proxy, project) {
if (!parts.url || !shouldRoute(parts.url, proxy)) { if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0; return void 0;
} }
@ -12747,7 +12754,7 @@ function routedNodeOptions(parts, proxy) {
hostname: nextUrl.hostname, hostname: nextUrl.hostname,
port: nextUrl.port || void 0, port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`, path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath) headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
}; };
} }
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) { function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@ -12758,7 +12765,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
} }
const proxy = normalizeProxyUrl(state.proxyUrl); const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args); const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy); const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) { if (!nextOptions) {
return Reflect.apply(originalRequest, this, args); return Reflect.apply(originalRequest, this, args);
} }
@ -12794,6 +12801,7 @@ function installHeadroomTransport(options) {
if (existing) { if (existing) {
existing.refs += 1; existing.refs += 1;
existing.proxyUrl = options.proxyUrl; existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug); existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl); installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport(); return () => uninstallHeadroomTransport();
@ -12801,6 +12809,7 @@ function installHeadroomTransport(options) {
const state = { const state = {
refs: 1, refs: 1,
proxyUrl: options.proxyUrl, proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug), debug: Boolean(options.debug),
originalFetch: globalThis.fetch, originalFetch: globalThis.fetch,
originalHttpRequest: http.request, originalHttpRequest: http.request,
@ -12821,7 +12830,7 @@ function installHeadroomTransport(options) {
return state.originalFetch(...args); return state.originalFetch(...args);
} }
const proxy = normalizeProxyUrl(current.proxyUrl); const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy); const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit); return state.originalFetch(nextInput, nextInit);
}; };
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest); http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
@ -12871,9 +12880,11 @@ function resolveProxyUrl(options) {
var HeadroomPlugin = async (input, options = {}) => { var HeadroomPlugin = async (input, options = {}) => {
const pluginOptions = options; const pluginOptions = options;
const proxyUrl = resolveProxyUrl(pluginOptions); const proxyUrl = resolveProxyUrl(pluginOptions);
const project = pluginOptions.project ?? input.project?.id ?? input.directory;
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl }); const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
const uninstallTransport = installHeadroomTransport({ const uninstallTransport = installHeadroomTransport({
proxyUrl, proxyUrl,
project,
debug: pluginOptions.debug debug: pluginOptions.debug
}); });
return { return {
@ -12894,7 +12905,7 @@ var HeadroomPlugin = async (input, options = {}) => {
"shell.env": async (_input, output) => { "shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1"; output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl; output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT = pluginOptions.project ?? input.project.id ?? input.directory; output.env.HEADROOM_PROJECT = project;
if (pluginOptions.backend) { if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend; output.env.HEADROOM_BACKEND = pluginOptions.backend;
} }

View file

@ -8,6 +8,7 @@ var childProcess = nodeRequire("node:child_process");
var fs = nodeRequire("node:fs"); var fs = nodeRequire("node:fs");
var BASE_URL_HEADER = "x-headroom-base-url"; var BASE_URL_HEADER = "x-headroom-base-url";
var ORIGINAL_PATH_HEADER = "x-headroom-original-path"; var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
var PROJECT_HEADER = "x-headroom-project";
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL"; var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport"); var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
function getState() { function getState() {
@ -156,7 +157,7 @@ function requestUrl(input) {
} }
return new URL(String(input)); return new URL(String(input));
} }
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) { function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
const headers = new Headers(input instanceof Request ? input.headers : void 0); const headers = new Headers(input instanceof Request ? input.headers : void 0);
if (init?.headers) { if (init?.headers) {
new Headers(init.headers).forEach((value, key) => headers.set(key, value)); new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@ -168,9 +169,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
if (originalPath) { if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath); headers.set(ORIGINAL_PATH_HEADER, originalPath);
} }
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers; return headers;
} }
function withRoutedFetchInput(input, init, proxy) { function withRoutedFetchInput(input, init, proxy, project) {
const upstream = requestUrl(input); const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) { if (!shouldRoute(upstream, proxy)) {
return [input, init]; return [input, init];
@ -178,7 +182,7 @@ function withRoutedFetchInput(input, init, proxy) {
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy); const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = { const nextInit = {
...init, ...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath) headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
}; };
if (input instanceof Request) { if (input instanceof Request) {
return [new Request(nextUrl, input), nextInit]; return [new Request(nextUrl, input), nextInit];
@ -224,12 +228,15 @@ function urlFromRequestOptions(options) {
return void 0; return void 0;
} }
} }
function headersForNodeRequest(options, upstream, originalPath) { function headersForNodeRequest(options, upstream, originalPath, project) {
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
headers.set(BASE_URL_HEADER, upstream.origin); headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) { if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath); headers.set(ORIGINAL_PATH_HEADER, originalPath);
} }
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host"); headers.delete("host");
const result = {}; const result = {};
headers.forEach((value, key) => { headers.forEach((value, key) => {
@ -237,7 +244,7 @@ function headersForNodeRequest(options, upstream, originalPath) {
}); });
return result; return result;
} }
function routedNodeOptions(parts, proxy) { function routedNodeOptions(parts, proxy, project) {
if (!parts.url || !shouldRoute(parts.url, proxy)) { if (!parts.url || !shouldRoute(parts.url, proxy)) {
return void 0; return void 0;
} }
@ -268,7 +275,7 @@ function routedNodeOptions(parts, proxy) {
hostname: nextUrl.hostname, hostname: nextUrl.hostname,
port: nextUrl.port || void 0, port: nextUrl.port || void 0,
path: `${nextUrl.pathname}${nextUrl.search}`, path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath) headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
}; };
} }
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) { function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@ -279,7 +286,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
} }
const proxy = normalizeProxyUrl(state.proxyUrl); const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args); const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy); const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) { if (!nextOptions) {
return Reflect.apply(originalRequest, this, args); return Reflect.apply(originalRequest, this, args);
} }
@ -315,6 +322,7 @@ function installHeadroomTransport(options) {
if (existing) { if (existing) {
existing.refs += 1; existing.refs += 1;
existing.proxyUrl = options.proxyUrl; existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug); existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl); installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport(); return () => uninstallHeadroomTransport();
@ -322,6 +330,7 @@ function installHeadroomTransport(options) {
const state = { const state = {
refs: 1, refs: 1,
proxyUrl: options.proxyUrl, proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug), debug: Boolean(options.debug),
originalFetch: globalThis.fetch, originalFetch: globalThis.fetch,
originalHttpRequest: http.request, originalHttpRequest: http.request,
@ -342,7 +351,7 @@ function installHeadroomTransport(options) {
return state.originalFetch(...args); return state.originalFetch(...args);
} }
const proxy = normalizeProxyUrl(current.proxyUrl); const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy); const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit); return state.originalFetch(nextInput, nextInit);
}; };
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest); http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);

View file

@ -28,9 +28,14 @@ function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
export const HeadroomPlugin: Plugin = async (input, options = {}) => { export const HeadroomPlugin: Plugin = async (input, options = {}) => {
const pluginOptions = options as HeadroomOpenCodePluginOptions; const pluginOptions = options as HeadroomOpenCodePluginOptions;
const proxyUrl = resolveProxyUrl(pluginOptions); const proxyUrl = resolveProxyUrl(pluginOptions);
const project =
pluginOptions.project ??
(input.project as { id?: string } | undefined)?.id ??
input.directory;
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl }); const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
const uninstallTransport = installHeadroomTransport({ const uninstallTransport = installHeadroomTransport({
proxyUrl, proxyUrl,
project,
debug: pluginOptions.debug, debug: pluginOptions.debug,
}); });
@ -54,10 +59,7 @@ export const HeadroomPlugin: Plugin = async (input, options = {}) => {
"shell.env": async (_input, output) => { "shell.env": async (_input, output) => {
output.env.HEADROOM_ACTIVE = "1"; output.env.HEADROOM_ACTIVE = "1";
output.env.HEADROOM_PROXY_URL = proxyUrl; output.env.HEADROOM_PROXY_URL = proxyUrl;
output.env.HEADROOM_PROJECT = output.env.HEADROOM_PROJECT = project;
pluginOptions.project ??
(input.project as { id?: string }).id ??
input.directory;
if (pluginOptions.backend) { if (pluginOptions.backend) {
output.env.HEADROOM_BACKEND = pluginOptions.backend; output.env.HEADROOM_BACKEND = pluginOptions.backend;
} }

View file

@ -386,6 +386,58 @@ describe("Headroom OpenCode transport", () => {
} }
}); });
it("sends x-headroom-project header on routed fetch calls when project is set", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1", project: "my-project" });
await fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("x-headroom-project")).toBe("my-project");
globalThis.fetch = originalFetch;
});
it("omits x-headroom-project header when project is not set", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
globalThis.fetch = fetchMock as unknown as typeof fetch;
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
await fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("x-headroom-project")).toBeNull();
globalThis.fetch = originalFetch;
});
it("sends x-headroom-project header on routed Node https.request calls when project is set", async () => {
const proxy = await proxyServer();
installHeadroomTransport({ proxyUrl: proxy.url, project: "my-project" });
await new Promise<void>((resolve, reject) => {
const req = https.request(
"https://api.anthropic.com/v1/messages",
{ method: "POST" },
(res) => {
res.resume();
res.on("end", resolve);
},
);
req.on("error", reject);
req.end("{}");
});
expect(proxy.seen[0].headers["x-headroom-project"]).toBe("my-project");
await proxy.close();
});
it("restores patched transports only after the final disposer", () => { it("restores patched transports only after the final disposer", () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const originalHttpRequest = http.request; const originalHttpRequest = http.request;

View file

@ -9,6 +9,7 @@ const fs = nodeRequire("node:fs") as typeof import("node:fs");
const BASE_URL_HEADER = "x-headroom-base-url"; const BASE_URL_HEADER = "x-headroom-base-url";
const ORIGINAL_PATH_HEADER = "x-headroom-original-path"; const ORIGINAL_PATH_HEADER = "x-headroom-original-path";
const PROJECT_HEADER = "x-headroom-project";
const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL"; const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
const STATE_KEY = Symbol.for("headroom.opencode.transport"); const STATE_KEY = Symbol.for("headroom.opencode.transport");
@ -25,12 +26,14 @@ type ChildFork = typeof childProcess.fork;
interface InstallOptions { interface InstallOptions {
proxyUrl: string; proxyUrl: string;
project?: string;
debug?: boolean; debug?: boolean;
} }
interface TransportState { interface TransportState {
refs: number; refs: number;
proxyUrl: string; proxyUrl: string;
project: string | undefined;
debug: boolean; debug: boolean;
originalFetch: typeof fetch; originalFetch: typeof fetch;
originalHttpRequest: HttpRequest; originalHttpRequest: HttpRequest;
@ -233,6 +236,7 @@ function mergeFetchHeaders(
init: RequestInit | undefined, init: RequestInit | undefined,
upstream: URL | undefined, upstream: URL | undefined,
originalPath: string | undefined = undefined, originalPath: string | undefined = undefined,
project: string | undefined = undefined,
): Headers { ): Headers {
const headers = new Headers(input instanceof Request ? input.headers : undefined); const headers = new Headers(input instanceof Request ? input.headers : undefined);
if (init?.headers) { if (init?.headers) {
@ -245,10 +249,13 @@ function mergeFetchHeaders(
if (originalPath) { if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath); headers.set(ORIGINAL_PATH_HEADER, originalPath);
} }
if (project) {
headers.set(PROJECT_HEADER, project);
}
return headers; return headers;
} }
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL): FetchArgs { function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL, project: string | undefined): FetchArgs {
const upstream = requestUrl(input); const upstream = requestUrl(input);
if (!shouldRoute(upstream, proxy)) { if (!shouldRoute(upstream, proxy)) {
return [input, init]; return [input, init];
@ -257,7 +264,7 @@ function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | unde
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy); const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
const nextInit = { const nextInit = {
...init, ...init,
headers: mergeFetchHeaders(input, init, upstream, originalPath), headers: mergeFetchHeaders(input, init, upstream, originalPath, project),
}; };
if (input instanceof Request) { if (input instanceof Request) {
@ -314,12 +321,16 @@ function headersForNodeRequest(
options: Record<string, unknown>, options: Record<string, unknown>,
upstream: URL, upstream: URL,
originalPath: string | undefined, originalPath: string | undefined,
project: string | undefined,
): Record<string, string> { ): Record<string, string> {
const headers = new Headers(options.headers as HeadersInit | undefined); const headers = new Headers(options.headers as HeadersInit | undefined);
headers.set(BASE_URL_HEADER, upstream.origin); headers.set(BASE_URL_HEADER, upstream.origin);
if (originalPath) { if (originalPath) {
headers.set(ORIGINAL_PATH_HEADER, originalPath); headers.set(ORIGINAL_PATH_HEADER, originalPath);
} }
if (project) {
headers.set(PROJECT_HEADER, project);
}
headers.delete("host"); headers.delete("host");
const result: Record<string, string> = {}; const result: Record<string, string> = {};
@ -329,7 +340,7 @@ function headersForNodeRequest(
return result; return result;
} }
function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string, unknown> | undefined { function routedNodeOptions(parts: NodeRequestParts, proxy: URL, project: string | undefined): Record<string, unknown> | undefined {
if (!parts.url || !shouldRoute(parts.url, proxy)) { if (!parts.url || !shouldRoute(parts.url, proxy)) {
return undefined; return undefined;
} }
@ -362,7 +373,7 @@ function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string,
hostname: nextUrl.hostname, hostname: nextUrl.hostname,
port: nextUrl.port || undefined, port: nextUrl.port || undefined,
path: `${nextUrl.pathname}${nextUrl.search}`, path: `${nextUrl.pathname}${nextUrl.search}`,
headers: headersForNodeRequest(parts.options, parts.url, originalPath), headers: headersForNodeRequest(parts.options, parts.url, originalPath, project),
}; };
} }
@ -379,7 +390,7 @@ function wrapRequest(
const proxy = normalizeProxyUrl(state.proxyUrl); const proxy = normalizeProxyUrl(state.proxyUrl);
const parts = splitNodeArgs(args); const parts = splitNodeArgs(args);
const nextOptions = routedNodeOptions(parts, proxy); const nextOptions = routedNodeOptions(parts, proxy, state.project);
if (!nextOptions) { if (!nextOptions) {
return Reflect.apply(originalRequest, this, args); return Reflect.apply(originalRequest, this, args);
} }
@ -420,6 +431,7 @@ export function installHeadroomTransport(options: InstallOptions): () => void {
if (existing) { if (existing) {
existing.refs += 1; existing.refs += 1;
existing.proxyUrl = options.proxyUrl; existing.proxyUrl = options.proxyUrl;
existing.project = options.project;
existing.debug = Boolean(options.debug); existing.debug = Boolean(options.debug);
installProcessEnv(options.proxyUrl); installProcessEnv(options.proxyUrl);
return () => uninstallHeadroomTransport(); return () => uninstallHeadroomTransport();
@ -428,6 +440,7 @@ export function installHeadroomTransport(options: InstallOptions): () => void {
const state: TransportState = { const state: TransportState = {
refs: 1, refs: 1,
proxyUrl: options.proxyUrl, proxyUrl: options.proxyUrl,
project: options.project,
debug: Boolean(options.debug), debug: Boolean(options.debug),
originalFetch: globalThis.fetch, originalFetch: globalThis.fetch,
originalHttpRequest: http.request, originalHttpRequest: http.request,
@ -449,7 +462,7 @@ export function installHeadroomTransport(options: InstallOptions): () => void {
return state.originalFetch(...args); return state.originalFetch(...args);
} }
const proxy = normalizeProxyUrl(current.proxyUrl); const proxy = normalizeProxyUrl(current.proxyUrl);
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy); const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
return state.originalFetch(nextInput, nextInit); return state.originalFetch(nextInput, nextInit);
}; };