Skip to content

Commit 3b7fed0

Browse files
KurtGokhanclaude
andcommitted
feat: add typed context injection for interceptors (.provides/.requires)
Interceptors can now inject strongly-typed context into the pipeline via .provides<T>() and declare context dependencies via .requires<T>(). Context flows through start-phase next({ context }) and merges with user-defined context in action handlers. Compile-time rejection when required context is not satisfied. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aa0b568 commit 3b7fed0

11 files changed

Lines changed: 648 additions & 55 deletions

File tree

.changeset/context-interceptors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'padrone': minor
3+
---
4+
5+
Add typed context injection for interceptors. Interceptors can declare provided context via `.provides<T>()` and required context via `.requires<T>()` on `defineInterceptor()`. Action handlers see the full merged context type. `.intercept()` rejects interceptors whose required context is not satisfied at compile time.

packages/padrone/src/core/exec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,13 @@ export function execCommand(
147147
const rootRegistered = rootCommand.interceptors ?? [];
148148
const rootInterceptors = resolveRegisteredInterceptors(rootRegistered, factoryCache);
149149

150-
const runPipeline = (signal: AbortSignal) => {
150+
const runPipeline = (signal: AbortSignal, pipelineContext: unknown) => {
151151
// ── Phase 1: Parse ──────────────────────────────────────────────────
152152
const parseCtx: InterceptorParseContext = {
153153
input: resolvedInput,
154154
command: rootCommand,
155155
signal,
156-
context: initialContext,
156+
context: pipelineContext,
157157
runtime,
158158
program: ctx.builder,
159159
caller,
@@ -170,7 +170,7 @@ export function execCommand(
170170
pipelineState.rawArgs = parsed.rawArgs;
171171
pipelineState.positionalArgs = parsed.positionalArgs;
172172
const commandInterceptors = resolveRegisteredInterceptors(collectInterceptorsFn(command), factoryCache);
173-
const context = resolveContext(command, initialContext);
173+
const context = resolveContext(command, pipelineContext);
174174

175175
// ── Phase 2: Validate ───────────────────────────────────────────
176176
const validateCtx: InterceptorValidateContext = {

packages/padrone/src/core/interceptors.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export function defineInterceptor<TArgs = unknown, TResult = unknown>(
4040
if (meta.id !== undefined) (factory as any).id = meta.id;
4141
if (meta.order !== undefined) (factory as any).order = meta.order;
4242
if (meta.disabled !== undefined) (factory as any).disabled = meta.disabled;
43+
// No-ops at runtime — purely type-level casts for context-providing/requiring interceptors.
44+
(factory as any).provides = () => factory;
45+
(factory as any).requires = () => factory;
4346
return factory as PadroneInterceptorFn<TArgs, TResult>;
4447
}
4548

@@ -168,7 +171,7 @@ export function wrapWithLifecycle<T>(
168171
interceptors: ResolvedInterceptor[],
169172
command: AnyPadroneCommand,
170173
input: string | undefined,
171-
pipeline: (signal: AbortSignal) => T | Promise<T>,
174+
pipeline: (signal: AbortSignal, context: unknown) => T | Promise<T>,
172175
wrapErrorResult?: (result: unknown) => T,
173176
signal?: AbortSignal,
174177
context?: unknown,
@@ -183,10 +186,11 @@ export function wrapWithLifecycle<T>(
183186
const hasShutdown = interceptors.some((p) => p.shutdown);
184187

185188
// Fast path: no lifecycle interceptors
186-
if (!hasStart && !hasError && !hasShutdown) return pipeline(signal ?? defaultSignal);
187-
// Mutable ref: start-phase interceptors can override the signal (e.g., signal extension),
188-
// and the override propagates to error/shutdown contexts.
189+
if (!hasStart && !hasError && !hasShutdown) return pipeline(signal ?? defaultSignal, context);
190+
// Mutable refs: start-phase interceptors can override signal and context (e.g., signal extension, auth),
191+
// and the overrides propagate to error/shutdown contexts.
189192
let effectiveSignal = signal ?? defaultSignal;
193+
let effectiveContext = context;
190194

191195
const runShutdown = (error?: unknown, result?: unknown) => {
192196
if (!hasShutdown) return;
@@ -196,7 +200,7 @@ export function wrapWithLifecycle<T>(
196200
error,
197201
result,
198202
signal: effectiveSignal,
199-
context,
203+
context: effectiveContext,
200204
runtime: runtime!,
201205
program: program!,
202206
caller,
@@ -219,7 +223,7 @@ export function wrapWithLifecycle<T>(
219223
input,
220224
error,
221225
signal: effectiveSignal,
222-
context,
226+
context: effectiveContext,
223227
runtime: runtime!,
224228
program: program!,
225229
caller,
@@ -248,7 +252,7 @@ export function wrapWithLifecycle<T>(
248252
const startCtx: InterceptorStartContext = {
249253
command,
250254
signal: effectiveSignal,
251-
context,
255+
context: effectiveContext,
252256
runtime: runtime!,
253257
program: program!,
254258
input,
@@ -259,11 +263,12 @@ export function wrapWithLifecycle<T>(
259263
result = (
260264
hasStart
261265
? runInterceptorChain('start', interceptors, startCtx, (ctx) => {
262-
// Capture the (possibly overridden) signal so error/shutdown phases see the same instance.
266+
// Capture overrides from start-phase interceptors so downstream phases see them.
263267
effectiveSignal = ctx.signal;
264-
return pipeline(ctx.signal);
268+
effectiveContext = ctx.context;
269+
return pipeline(ctx.signal, ctx.context);
265270
})
266-
: pipeline(effectiveSignal)
271+
: pipeline(effectiveSignal, effectiveContext)
267272
) as T | Promise<T>;
268273
} catch (e) {
269274
return runError(e);

packages/padrone/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export type {
6363
AnyPadroneProgram,
6464
AsyncPadroneSchema,
6565
CommandTypesBase,
66+
ExtractInterceptorContext,
67+
ExtractInterceptorRequires,
6668
GetArgsMeta,
6769
InterceptorBaseContext,
6870
InterceptorErrorContext,
@@ -82,6 +84,7 @@ export type {
8284
PadroneBuilder,
8385
PadroneCommand,
8486
PadroneCommandResult,
87+
PadroneContextInterceptor,
8588
PadroneDrainResult,
8689
PadroneExtension,
8790
PadroneInterceptor,
@@ -92,5 +95,13 @@ export type {
9295
} from './types/index.ts';
9396
export type { AsyncStreamMeta } from './util/stream.ts';
9497
export { asyncStream } from './util/stream.ts';
95-
export type { InferArgsInput, InferArgsOutput, InferCommand, InferContext } from './util/type-helpers.ts';
98+
export type {
99+
InferArgsInput,
100+
InferArgsOutput,
101+
InferCommand,
102+
InferContext,
103+
InferContextProvided,
104+
InferInterceptorContext,
105+
InferInterceptorRequires,
106+
} from './util/type-helpers.ts';
96107
export type { Drained } from './util/type-utils.ts';

0 commit comments

Comments
 (0)