-
Notifications
You must be signed in to change notification settings - Fork 160
feat(event-handlers): introduce event handler system for GitProxy lifecycle events #1519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * Copyright 2026 GitProxy Contributors | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| import { IProxyEventRegistry } from './types'; | ||
|
|
||
| /** | ||
| * A plugin which subscribes one or more event handlers to GitProxy lifecycle | ||
| * events. Loaded at startup from the `eventHandlers` config key. | ||
| * | ||
| * Subclasses (or instances constructed with a `register` function) declare | ||
| * their subscriptions inside `register(registry)`. The registry is the only | ||
| * way handlers are wired in; event handler plugins do not participate in the | ||
| * action chain and cannot block or modify operations. | ||
| */ | ||
| export class EventHandlerPlugin { | ||
| isGitProxyPlugin: boolean; | ||
| isGitProxyEventHandlerPlugin: boolean; | ||
| register: (registry: IProxyEventRegistry) => void; | ||
|
|
||
| constructor(register: (registry: IProxyEventRegistry) => void) { | ||
| this.isGitProxyPlugin = true; | ||
| this.isGitProxyEventHandlerPlugin = true; | ||
| this.register = register; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether a value looks like an EventHandlerPlugin (loaded from an | ||
| * external module). Mirrors `isCompatiblePlugin` in src/plugin.ts but checks | ||
| * for `register` rather than `exec`. | ||
| */ | ||
| export const isEventHandlerPlugin = (obj: unknown): obj is EventHandlerPlugin => { | ||
| let cursor: unknown = obj; | ||
| while (cursor != null) { | ||
| if ( | ||
| typeof cursor === 'object' && | ||
| Object.prototype.hasOwnProperty.call(cursor, 'isGitProxyEventHandlerPlugin') && | ||
| (cursor as { isGitProxyEventHandlerPlugin?: unknown }).isGitProxyEventHandlerPlugin === | ||
| true && | ||
| typeof (cursor as { register?: unknown }).register === 'function' | ||
| ) { | ||
| return true; | ||
| } | ||
| cursor = Object.getPrototypeOf(cursor); | ||
| } | ||
| return false; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * Copyright 2026 GitProxy Contributors | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| import { EventHandlerPlugin } from '../EventHandlerPlugin'; | ||
| import { ActionPhase, EventDetails, IProxyEventRegistry, ProxyOperation } from '../types'; | ||
|
|
||
| const formatLine = (details: EventDetails): string => { | ||
| const payload: Record<string, unknown> = { | ||
| event: 'gitproxy.action', | ||
| actionId: details.actionId, | ||
| operation: details.operation, | ||
| phase: details.phase, | ||
| timestamp: new Date(details.timestamp).toISOString(), | ||
| repo: details.repository.url, | ||
| }; | ||
| if (details.user?.username) payload.username = details.user.username; | ||
| if (details.user?.email) payload.userEmail = details.user.email; | ||
| if (details.error) payload.error = details.error.message; | ||
| return JSON.stringify(payload); | ||
| }; | ||
|
|
||
| const log = (details: EventDetails): void => { | ||
| console.info(formatLine(details)); | ||
| }; | ||
|
|
||
| const phases: ActionPhase[] = ['started', 'completed', 'error', 'permissionDenied']; | ||
| const operations: ProxyOperation[] = ['push', 'pull']; | ||
|
|
||
| /** | ||
| * Built-in event handler that emits a structured JSON line for every | ||
| * lifecycle event. Always loaded so deployments get an automatic audit trail | ||
| * without needing to write or configure a handler. To silence, filter at the | ||
| * log aggregator on `"event":"gitproxy.action"`. | ||
| */ | ||
| export const consoleLoggerEventHandler = new EventHandlerPlugin(function registerConsoleLogger( | ||
| registry: IProxyEventRegistry, | ||
| ) { | ||
| for (const op of operations) { | ||
| const builder = op === 'push' ? registry.onPush() : registry.onPull(); | ||
| for (const phase of phases) { | ||
| if (phase === 'started') builder.onStarted(log); | ||
| else if (phase === 'completed') builder.onCompleted(log); | ||
| else if (phase === 'error') builder.onError(log); | ||
| else if (phase === 'permissionDenied') builder.onPermissionDenied(log); | ||
| } | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| /** | ||
| * Copyright 2026 GitProxy Contributors | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| import { Action } from '../proxy/actions'; | ||
| import { ProxyEventRegistry } from './registry'; | ||
| import { | ||
| ActionEventCallback, | ||
| ActionPhase, | ||
| EventDetails, | ||
| ProxyOperation, | ||
| RepositoryContext, | ||
| UserContext, | ||
| } from './types'; | ||
|
|
||
| const buildRepositoryContext = (action: Action): RepositoryContext => ({ | ||
| url: action.url, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The same problem like line 38, don't return references, return values instead: |
||
| project: action.project, | ||
| name: action.repoName, | ||
| }); | ||
|
|
||
| const buildUserContext = (action: Action): UserContext | undefined => { | ||
| if (!action.user && !action.userEmail) { | ||
| return undefined; | ||
| } | ||
| return { username: action.user, email: action.userEmail }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. EventDetails.user and repository must be immutable copies (using String()) of Action fields — not references. Otherwise, async handlers may see stale or modified data if Action is mutated later in the chain. This rather critical I believe, but easy to fix: |
||
| }; | ||
|
|
||
| /** | ||
| * Translates an Action's type string to the ProxyOperation surfaced to | ||
| * handlers, or null when the action should not produce an event (e.g. | ||
| * "default" actions used for protocol pings). | ||
| */ | ||
| const operationFor = (action: Action): ProxyOperation | null => { | ||
| if (action.type === 'push') return 'push'; | ||
| if (action.type === 'pull') return 'pull'; | ||
| return null; | ||
| }; | ||
|
|
||
| export class EventDispatcher { | ||
| private readonly registry: ProxyEventRegistry; | ||
| private readonly inflight: Set<Promise<void>> = new Set(); | ||
|
|
||
| constructor(registry: ProxyEventRegistry) { | ||
| this.registry = registry; | ||
| } | ||
|
|
||
| /** | ||
| * Invokes registered handlers for a phase. Returns synchronously — handlers | ||
| * run on the event loop without delaying the request path. Errors thrown by | ||
| * a handler are caught and logged; they never propagate. | ||
| */ | ||
| public dispatch(action: Action, phase: ActionPhase, error?: Error): void { | ||
| const operation = operationFor(action); | ||
| if (!operation) return; | ||
|
|
||
| const handlers = this.registry.get(operation, phase); | ||
| if (handlers.length === 0) return; | ||
|
|
||
| const details: EventDetails = { | ||
| actionId: action.id, | ||
| operation, | ||
| phase, | ||
| timestamp: Date.now(), | ||
| repository: buildRepositoryContext(action), | ||
| user: buildUserContext(action), | ||
| error, | ||
| }; | ||
|
|
||
| for (const handler of handlers) { | ||
| this.invoke(handler, details); | ||
| } | ||
| } | ||
|
|
||
| private invoke(handler: ActionEventCallback, details: EventDetails): void { | ||
| const handlerName = handler.name || 'anonymous'; | ||
| let promise: Promise<void>; | ||
| try { | ||
| promise = Promise.resolve(handler(details)); | ||
| } catch (err) { | ||
| this.logHandlerError(handlerName, details, err); | ||
| return; | ||
| } | ||
|
|
||
| const tracked = promise.catch((err) => this.logHandlerError(handlerName, details, err)); | ||
| this.inflight.add(tracked); | ||
| tracked.finally(() => this.inflight.delete(tracked)); | ||
| } | ||
|
|
||
| private logHandlerError(handlerName: string, details: EventDetails, err: unknown): void { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error( | ||
| `Event handler "${handlerName}" failed for ${details.operation}.${details.phase}: ${message}`, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Awaits in-flight handler promises, bounded by `timeoutMs`. Used by | ||
| * Proxy.stop() to give handlers a chance to finish before the process | ||
| * exits. Handlers still running past the timeout are abandoned with a warning. | ||
| */ | ||
| async drain(timeoutMs: number): Promise<void> { | ||
| if (this.inflight.size === 0) return; | ||
|
|
||
| const inflightCount = this.inflight.size; | ||
| const all = Promise.allSettled([...this.inflight]).then(() => undefined); | ||
| const timeout = new Promise<'timeout'>((resolve) => | ||
| setTimeout(() => resolve('timeout'), timeoutMs), | ||
| ); | ||
| const result = await Promise.race([all.then(() => 'done' as const), timeout]); | ||
| if (result === 'timeout') { | ||
| console.warn( | ||
| `Event dispatcher drain timed out after ${timeoutMs}ms with ${this.inflight.size}/${inflightCount} handler(s) still running; abandoning.`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let activeDispatcher: EventDispatcher | null = null; | ||
|
|
||
| export const setEventDispatcher = (dispatcher: EventDispatcher | null): void => { | ||
| activeDispatcher = dispatcher; | ||
| }; | ||
|
|
||
| export const getEventDispatcher = (): EventDispatcher | null => activeDispatcher; | ||
|
|
||
| export const resetEventDispatcher = (): void => { | ||
| activeDispatcher = null; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /** | ||
| * Copyright 2026 GitProxy Contributors | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| export * from './types'; | ||
| export { EventHandlerPlugin, isEventHandlerPlugin } from './EventHandlerPlugin'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The config schema shows the eventHandlers array, but there are no examples of how to configure event handlers in proxy.config.json. Consider to document example