Skip to content

Commit 13744b2

Browse files
authored
Derive IPC handlers and API hooks from TasksApi at compile time (#805)
Replace hand-written, per-method handler maps and API hook with mapped types and builder functions that derive everything from the TasksApi definition object. This eliminates the duplication where every method was specified three times (definition, extension handler, webview hook). - Add `kind` discriminants to definition interfaces for runtime dispatch - Add mapped types (`RequestHandlerMap`, `CommandHandlerMap`, `ApiHook`) that enforce compile-time handler completeness - Add `buildRequestHandlers()`, `buildCommandHandlers()`, `buildApiHook()` builders - Replace per-entry `requestHandler()`/`commandHandler()` with builders in `tasksPanelProvider` - Auto-generate `useTasksApi()` via `buildApiHook(TasksApi, useIpc())` - Add typed `notify()` replacing raw `sendNotification()` calls - Inline `TasksApi` definitions, update call sites to use params objects
1 parent 1d32675 commit 13744b2

File tree

11 files changed

+209
-222
lines changed

11 files changed

+209
-222
lines changed

packages/shared/src/ipc/protocol.ts

Lines changed: 134 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,23 @@
99

1010
/** Request definition: params P, response R */
1111
export interface RequestDef<P = void, R = void> {
12+
readonly kind: "request";
1213
readonly method: string;
1314
/** @internal Phantom types for inference - not present at runtime */
1415
readonly _types?: { params: P; response: R };
1516
}
1617

1718
/** Command definition: params P, no response */
1819
export interface CommandDef<P = void> {
20+
readonly kind: "command";
1921
readonly method: string;
2022
/** @internal Phantom type for inference - not present at runtime */
2123
readonly _types?: { params: P };
2224
}
2325

2426
/** Notification definition: data D (extension to webview) */
2527
export interface NotificationDef<D = void> {
28+
readonly kind: "notification";
2629
readonly method: string;
2730
/** @internal Phantom type for inference - not present at runtime */
2831
readonly _types?: { data: D };
@@ -34,19 +37,19 @@ export interface NotificationDef<D = void> {
3437
export function defineRequest<P = void, R = void>(
3538
method: string,
3639
): RequestDef<P, R> {
37-
return { method } as RequestDef<P, R>;
40+
return { kind: "request", method } as RequestDef<P, R>;
3841
}
3942

4043
/** Define a fire-and-forget command */
4144
export function defineCommand<P = void>(method: string): CommandDef<P> {
42-
return { method } as CommandDef<P>;
45+
return { kind: "command", method } as CommandDef<P>;
4346
}
4447

4548
/** Define a push notification (extension to webview) */
4649
export function defineNotification<D = void>(
4750
method: string,
4851
): NotificationDef<D> {
49-
return { method } as NotificationDef<D>;
52+
return { kind: "notification", method } as NotificationDef<D>;
5053
}
5154

5255
// --- Wire format ---
@@ -73,28 +76,135 @@ export interface IpcNotification<D = unknown> {
7376
readonly data?: D;
7477
}
7578

76-
// --- Handler utilities ---
77-
78-
/** Extract params type from a request/command definition */
79-
export type ParamsOf<T> = T extends { _types?: { params: infer P } } ? P : void;
80-
81-
/** Extract response type from a request definition */
82-
export type ResponseOf<T> = T extends { _types?: { response: infer R } }
83-
? R
84-
: void;
79+
// --- Mapped types for handler completeness ---
80+
81+
/** Requires a handler for every RequestDef in Api. Compile error if one is missing. */
82+
export type RequestHandlerMap<Api> = {
83+
[K in keyof Api as Api[K] extends { kind: "request" }
84+
? K
85+
: never]: Api[K] extends RequestDef<infer P, infer R>
86+
? (params: P) => Promise<R>
87+
: never;
88+
};
89+
90+
/** Requires a handler for every CommandDef in Api. Compile error if one is missing. */
91+
export type CommandHandlerMap<Api> = {
92+
[K in keyof Api as Api[K] extends { kind: "command" }
93+
? K
94+
: never]: Api[K] extends CommandDef<infer P>
95+
? (params: P) => void | Promise<void>
96+
: never;
97+
};
98+
99+
// --- API hook type ---
100+
101+
/** Derives a fully typed hook interface from an API definition object. */
102+
export type ApiHook<Api> = {
103+
[K in keyof Api as Api[K] extends { kind: "request" }
104+
? K
105+
: never]: Api[K] extends RequestDef<infer P, infer R>
106+
? (...args: P extends void ? [] : [params: P]) => Promise<R>
107+
: never;
108+
} & {
109+
[K in keyof Api as Api[K] extends { kind: "command" }
110+
? K
111+
: never]: Api[K] extends CommandDef<infer P>
112+
? (...args: P extends void ? [] : [params: P]) => void
113+
: never;
114+
} & {
115+
[K in keyof Api as Api[K] extends { kind: "notification" }
116+
? `on${Capitalize<K & string>}`
117+
: never]: Api[K] extends NotificationDef<infer D>
118+
? D extends void
119+
? (cb: () => void) => () => void
120+
: (cb: (data: D) => void) => () => void
121+
: never;
122+
};
123+
124+
// --- Builder functions ---
125+
126+
/** Build a method-indexed map of request handlers with compile-time completeness. */
127+
export function buildRequestHandlers<
128+
Api extends Record<string, { method: string }>,
129+
>(
130+
api: Api,
131+
handlers: RequestHandlerMap<Api>,
132+
): Record<string, (params: unknown) => Promise<unknown>>;
133+
export function buildRequestHandlers(
134+
api: Record<string, { method: string }>,
135+
handlers: Record<string, (params: unknown) => Promise<unknown>>,
136+
) {
137+
const result: Record<string, (params: unknown) => Promise<unknown>> = {};
138+
for (const key of Object.keys(handlers)) {
139+
result[api[key].method] = handlers[key];
140+
}
141+
return result;
142+
}
85143

86-
/** Type-safe request handler - infers params and return type from definition */
87-
export function requestHandler<P, R>(
88-
_def: RequestDef<P, R>,
89-
fn: (params: P) => Promise<R>,
90-
): (params: unknown) => Promise<unknown> {
91-
return fn as (params: unknown) => Promise<unknown>;
144+
/** Build a method-indexed map of command handlers with compile-time completeness. */
145+
export function buildCommandHandlers<
146+
Api extends Record<string, { method: string }>,
147+
>(
148+
api: Api,
149+
handlers: CommandHandlerMap<Api>,
150+
): Record<string, (params: unknown) => void | Promise<void>>;
151+
export function buildCommandHandlers(
152+
api: Record<string, { method: string }>,
153+
handlers: Record<string, (params: unknown) => void | Promise<void>>,
154+
) {
155+
const result: Record<string, (params: unknown) => void | Promise<void>> = {};
156+
for (const key of Object.keys(handlers)) {
157+
result[api[key].method] = handlers[key];
158+
}
159+
return result;
92160
}
93161

94-
/** Type-safe command handler - infers params type from definition */
95-
export function commandHandler<P>(
96-
_def: CommandDef<P>,
97-
fn: (params: P) => void | Promise<void>,
98-
): (params: unknown) => void | Promise<void> {
99-
return fn as (params: unknown) => void | Promise<void>;
162+
/** Build a typed API hook from an API definition and IPC primitives. */
163+
export function buildApiHook<
164+
Api extends Record<string, { kind: string; method: string }>,
165+
>(
166+
api: Api,
167+
ipc: {
168+
request: <P, R>(
169+
def: { method: string; _types?: { params: P; response: R } },
170+
...args: P extends void ? [] : [params: P]
171+
) => Promise<R>;
172+
command: <P>(
173+
def: { method: string; _types?: { params: P } },
174+
...args: P extends void ? [] : [params: P]
175+
) => void;
176+
onNotification: <D>(
177+
def: { method: string; _types?: { data: D } },
178+
cb: (data: D) => void,
179+
) => () => void;
180+
},
181+
): ApiHook<Api>;
182+
export function buildApiHook(
183+
api: Record<string, { kind: string; method: string }>,
184+
ipc: {
185+
request: (def: { method: string }, params?: unknown) => Promise<unknown>;
186+
command: (def: { method: string }, params?: unknown) => void;
187+
onNotification: (
188+
def: { method: string },
189+
cb: (data: unknown) => void,
190+
) => () => void;
191+
},
192+
) {
193+
const result: Record<string, unknown> = {};
194+
for (const [key, def] of Object.entries(api)) {
195+
switch (def.kind) {
196+
case "request":
197+
result[key] = (params: unknown) => ipc.request(def, params);
198+
break;
199+
case "command":
200+
result[key] = (params: unknown) => ipc.command(def, params);
201+
break;
202+
case "notification":
203+
result[`on${key[0].toUpperCase()}${key.slice(1)}`] = (
204+
cb: (data: unknown) => void,
205+
) => ipc.onNotification(def, cb);
206+
break;
207+
}
208+
}
209+
return result;
100210
}

packages/shared/src/tasks/api.ts

Lines changed: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -21,65 +21,40 @@ export interface TaskIdParams {
2121
taskId: string;
2222
}
2323

24-
const getTasks = defineRequest<void, readonly Task[] | null>("getTasks");
25-
const getTemplates = defineRequest<void, readonly TaskTemplate[] | null>(
26-
"getTemplates",
27-
);
28-
const getTask = defineRequest<TaskIdParams, Task>("getTask");
29-
const getTaskDetails = defineRequest<TaskIdParams, TaskDetails>(
30-
"getTaskDetails",
31-
);
32-
3324
export interface CreateTaskParams {
3425
templateVersionId: string;
3526
prompt: string;
3627
presetId?: string;
3728
}
38-
const createTask = defineRequest<CreateTaskParams, Task>("createTask");
3929

4030
export interface TaskActionParams extends TaskIdParams {
4131
taskName: string;
4232
}
43-
const deleteTask = defineRequest<TaskActionParams, void>("deleteTask");
44-
const pauseTask = defineRequest<TaskActionParams, void>("pauseTask");
45-
const resumeTask = defineRequest<TaskActionParams, void>("resumeTask");
46-
const downloadLogs = defineRequest<TaskIdParams, void>("downloadLogs");
47-
const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(
48-
"sendTaskMessage",
49-
);
50-
51-
const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
52-
const viewLogs = defineCommand<TaskIdParams>("viewLogs");
53-
const stopStreamingWorkspaceLogs = defineCommand<void>(
54-
"stopStreamingWorkspaceLogs",
55-
);
56-
57-
const taskUpdated = defineNotification<Task>("taskUpdated");
58-
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
59-
const workspaceLogsAppend = defineNotification<string[]>("workspaceLogsAppend");
60-
const refresh = defineNotification<void>("refresh");
61-
const showCreateForm = defineNotification<void>("showCreateForm");
6233

6334
export const TasksApi = {
6435
// Requests
65-
getTasks,
66-
getTemplates,
67-
getTask,
68-
getTaskDetails,
69-
createTask,
70-
deleteTask,
71-
pauseTask,
72-
resumeTask,
73-
downloadLogs,
74-
sendTaskMessage,
36+
getTasks: defineRequest<void, readonly Task[] | null>("getTasks"),
37+
getTemplates: defineRequest<void, readonly TaskTemplate[] | null>(
38+
"getTemplates",
39+
),
40+
getTask: defineRequest<TaskIdParams, Task>("getTask"),
41+
getTaskDetails: defineRequest<TaskIdParams, TaskDetails>("getTaskDetails"),
42+
createTask: defineRequest<CreateTaskParams, Task>("createTask"),
43+
deleteTask: defineRequest<TaskActionParams, void>("deleteTask"),
44+
pauseTask: defineRequest<TaskActionParams, void>("pauseTask"),
45+
resumeTask: defineRequest<TaskActionParams, void>("resumeTask"),
46+
downloadLogs: defineRequest<TaskIdParams, void>("downloadLogs"),
47+
sendTaskMessage: defineRequest<TaskIdParams & { message: string }, void>(
48+
"sendTaskMessage",
49+
),
7550
// Commands
76-
viewInCoder,
77-
viewLogs,
78-
stopStreamingWorkspaceLogs,
51+
viewInCoder: defineCommand<TaskIdParams>("viewInCoder"),
52+
viewLogs: defineCommand<TaskIdParams>("viewLogs"),
53+
stopStreamingWorkspaceLogs: defineCommand<void>("stopStreamingWorkspaceLogs"),
7954
// Notifications
80-
taskUpdated,
81-
tasksUpdated,
82-
workspaceLogsAppend,
83-
refresh,
84-
showCreateForm,
55+
taskUpdated: defineNotification<Task>("taskUpdated"),
56+
tasksUpdated: defineNotification<Task[]>("tasksUpdated"),
57+
workspaceLogsAppend: defineNotification<string[]>("workspaceLogsAppend"),
58+
refresh: defineNotification<void>("refresh"),
59+
showCreateForm: defineNotification<void>("showCreateForm"),
8560
} as const;

packages/tasks/src/components/ErrorBanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function ErrorBanner({ task }: ErrorBannerProps) {
1919
<button
2020
type="button"
2121
className="text-link"
22-
onClick={() => api.viewLogs(task.id)}
22+
onClick={() => api.viewLogs({ taskId: task.id })}
2323
>
2424
View logs <VscodeIcon name="link-external" />
2525
</button>

packages/tasks/src/components/TaskMessageInput.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export function TaskMessageInput({ task }: TaskMessageInputProps) {
6969
});
7070

7171
const { mutate: sendMessage, isPending: isSending } = useMutation({
72-
mutationFn: (msg: string) => api.sendTaskMessage(task.id, msg),
72+
mutationFn: (msg: string) =>
73+
api.sendTaskMessage({ taskId: task.id, message: msg }),
7374
onSuccess: () => setMessage(""),
7475
onError: (err) => logger.error("Failed to send message", err),
7576
});

packages/tasks/src/components/useTaskMenuItems.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,14 @@ export function useTaskMenuItems({
7373
menuItems.push({
7474
label: "View in Coder",
7575
icon: "link-external",
76-
onClick: () => api.viewInCoder(task.id),
76+
onClick: () => api.viewInCoder({ taskId: task.id }),
7777
});
7878

7979
menuItems.push({
8080
label: "Download Logs",
8181
icon: "cloud-download",
82-
onClick: () => run("downloading", () => api.downloadLogs(task.id)),
82+
onClick: () =>
83+
run("downloading", () => api.downloadLogs({ taskId: task.id })),
8384
loading: action === "downloading",
8485
});
8586

packages/tasks/src/hooks/useSelectedTask.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function useSelectedTask(tasks: readonly Task[]) {
3030
? queryKeys.taskDetail(selectedTaskId)
3131
: queryKeys.details,
3232
queryFn: selectedTaskId
33-
? () => api.getTaskDetails(selectedTaskId)
33+
? () => api.getTaskDetails({ taskId: selectedTaskId })
3434
: skipToken,
3535
refetchInterval: (query) => {
3636
const task = query.state.data?.task;
Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,6 @@
1-
/**
2-
* Tasks API hook - provides type-safe access to all Tasks operations.
3-
*
4-
* @example
5-
* ```tsx
6-
* const api = useTasksApi();
7-
* const tasks = await api.getTasks();
8-
* api.viewInCoder("task-id");
9-
* ```
10-
*/
11-
12-
import {
13-
TasksApi,
14-
type CreateTaskParams,
15-
type Task,
16-
type TaskActionParams,
17-
} from "@repo/shared";
1+
import { TasksApi, buildApiHook } from "@repo/shared";
182
import { useIpc } from "@repo/webview-shared/react";
193

204
export function useTasksApi() {
21-
const { request, command, onNotification } = useIpc();
22-
23-
return {
24-
// Requests
25-
getTasks: () => request(TasksApi.getTasks),
26-
getTemplates: () => request(TasksApi.getTemplates),
27-
getTask: (taskId: string) => request(TasksApi.getTask, { taskId }),
28-
getTaskDetails: (taskId: string) =>
29-
request(TasksApi.getTaskDetails, { taskId }),
30-
createTask: (params: CreateTaskParams) =>
31-
request(TasksApi.createTask, params),
32-
deleteTask: (params: TaskActionParams) =>
33-
request(TasksApi.deleteTask, params),
34-
pauseTask: (params: TaskActionParams) =>
35-
request(TasksApi.pauseTask, params),
36-
resumeTask: (params: TaskActionParams) =>
37-
request(TasksApi.resumeTask, params),
38-
downloadLogs: (taskId: string) =>
39-
request(TasksApi.downloadLogs, { taskId }),
40-
sendTaskMessage: (taskId: string, message: string) =>
41-
request(TasksApi.sendTaskMessage, { taskId, message }),
42-
43-
// Commands
44-
viewInCoder: (taskId: string) => command(TasksApi.viewInCoder, { taskId }),
45-
viewLogs: (taskId: string) => command(TasksApi.viewLogs, { taskId }),
46-
stopStreamingWorkspaceLogs: () =>
47-
command(TasksApi.stopStreamingWorkspaceLogs),
48-
49-
// Notifications
50-
onTaskUpdated: (cb: (task: Task) => void) =>
51-
onNotification(TasksApi.taskUpdated, cb),
52-
onTasksUpdated: (cb: (tasks: Task[]) => void) =>
53-
onNotification(TasksApi.tasksUpdated, cb),
54-
onWorkspaceLogsAppend: (cb: (lines: string[]) => void) =>
55-
onNotification(TasksApi.workspaceLogsAppend, cb),
56-
onRefresh: (cb: () => void) => onNotification(TasksApi.refresh, cb),
57-
onShowCreateForm: (cb: () => void) =>
58-
onNotification(TasksApi.showCreateForm, cb),
59-
};
5+
return buildApiHook(TasksApi, useIpc());
606
}

0 commit comments

Comments
 (0)