94
});
95
}
97
>
/**
98
>
* Reusable base for the agent-host loopback HTTP proxies. Owns the
99
>
* full server lifecycle — lazy bind on `127.0.0.1`, nonce minting,
100
>
* refcounted handles, in-flight tracking, and teardown — so each concrete
101
>
* proxy only has to implement request routing (`handleRequest`) and the
102
>
* shape of its `state` (`createState`).
103
>
*
104
>
* `TState` is the subclass-owned per-bind mutable state; `TSeed` is the
105
>
* value each `acquire()` caller threads into `createState()` so the state
106
>
* is born valid (e.g. with a real GitHub token rather than a placeholder).
107
>
* It defaults to `void` for proxies whose state needs no seed.
108
>
*
109
>
* Lifecycle: the first `start()` binds a single shared server; concurrent
110
>
* `start()` calls share that bind. Each handle holds a refcount; when the
111
>
* last one is disposed (or `dispose()` is called explicitly) the listener
112
>
* closes, in-flight requests are aborted, and the next `start()` rebinds
113
>
* with a fresh port and nonce.
114
>
*/
115
>
export abstract class LoopbackProxyServer<TState, TSeed = void> {
116
>
117
>
private _runtime: IInternalRuntime<TState> | undefined;
118
>
private _starting: Promise<IInternalRuntime<TState>> | undefined;
119
>
private _disposed = false;
120
>
121
>
constructor(
122
/** Human-readable name used in log lines and error messages. */
123
protected readonly name: string,
124
protected readonly _logService: ILogService,
125
) { }
127
>
protected get isDisposed(): boolean {
128
return this._disposed;
129
}
131
>
/**
132
>
* Build the subclass-owned mutable state object stored on the runtime.
133
>
* Called exactly once per bind, before any request can be dispatched,
134
>
* with the `seed` from the `acquire()` call that won the bind race so
135
>
* the state starts out valid instead of holding a placeholder.
136
>
*/
137
>
protected abstract createState(seed: TSeed): TState;
138
>
139
>
/**
140
>
* Route + service an authenticated inbound request. Invoked for every
141
>
* request; any throw is caught by the base and turned into a 500.
142
>
*/
143
>
protected abstract handleRequest(
144
>
req: http.IncomingMessage,
145
>
res: http.ServerResponse,
146
>
runtime: ILoopbackProxyRuntime<TState>,
147
>
): Promise<void>;
148
>
149
>
/**
150
>
* Write the fallback "internal proxy error" response used when
151
>
* {@link handleRequest} throws before sending headers. Subclasses may
152
>
* override to match their wire format; the default emits a generic
153
>
* JSON error envelope.
154
>
*/
155
>
protected writeInternalError(res: http.ServerResponse): void {
156
res.writeHead(500, { 'Content-Type': 'application/json' });
157
res.end(JSON.stringify({ error: { type: 'api_error', message: 'Internal proxy error' } }));
158
}
160
>
/**
161
>
* Acquire a refcounted lease on the shared runtime, binding the server
162
>
* if it isn't running yet. Subclasses build their public handle around
163
>
* the returned `runtime` and wire its `dispose()` to `release`.
164
>
*
165
>
* `seed` is forwarded to {@link createState} when this call triggers the
166
>
* bind; for callers that join an existing bind it is ignored (the state
167
>
* already exists), so they must apply their own value to `runtime.state`
168
>
* afterwards if they need last-writer-wins semantics.
169
>
*
170
>
* Throws if the service has been disposed (including if `dispose()`
171
>
* raced the bind).
172
>
*/
173
>
protected async acquire(seed: TSeed): Promise<{ runtime: ILoopbackProxyRuntime<TState>; release: () => void }> {
174
if (this._disposed) {
175
throw new Error(`${this.name} has been disposed`);