@@ -67,13 +67,56 @@ export const MAIN_TOKENS = Object.freeze({
6767});
6868```
6969
70- ### Using a Service
70+ ### Injecting Dependencies
71+
72+ Services should declare dependencies via constructor injection:
73+
74+ ``` typescript
75+ import { inject , injectable } from " inversify" ;
76+ import { MAIN_TOKENS } from " ../di/tokens" ;
77+
78+ @injectable ()
79+ export class MyService {
80+ constructor (
81+ @inject (MAIN_TOKENS .OtherService )
82+ private readonly otherService : OtherService ,
83+ ) {}
84+
85+ doSomething() {
86+ return this .otherService .getData ();
87+ }
88+ }
89+ ```
90+
91+ ### Using Services in tRPC Routers
92+
93+ tRPC routers resolve services from the container:
7194
7295``` typescript
73- import { get } from " @main/di" ; // or @renderer/di
96+ import { container } from " ../../di/container" ;
97+ import { MAIN_TOKENS } from " ../../di/tokens" ;
98+
99+ const getService = () => container .get <MyService >(MAIN_TOKENS .MyService );
100+
101+ export const myRouter = router ({
102+ getData: publicProcedure .query (() => getService ().getData ()),
103+ });
104+ ```
74105
75- const myService = get <MyService >(TOKENS .MyService );
76- myService .doSomething ();
106+ ### Testing with Mocks
107+
108+ Constructor injection makes testing straightforward:
109+
110+ ``` typescript
111+ // Direct instantiation with mock
112+ const mockOtherService = { getData: vi .fn ().mockReturnValue (" test" ) };
113+ const service = new MyService (mockOtherService as OtherService );
114+
115+ // Or rebind in container for integration tests
116+ container .snapshot ();
117+ container .rebind (MAIN_TOKENS .OtherService ).toConstantValue (mockOtherService );
118+ // ... run tests ...
119+ container .restore ();
77120```
78121
79122## IPC via tRPC
@@ -84,27 +127,26 @@ We use [tRPC](https://trpc.io/) with [trpc-electron](https://github.com/jsonnull
84127
85128``` typescript
86129// src/main/trpc/routers/my-router.ts
87- import { z } from " zod" ;
130+ import { container } from " ../../di/container" ;
131+ import { MAIN_TOKENS } from " ../../di/tokens" ;
132+ import {
133+ getDataInput ,
134+ getDataOutput ,
135+ updateDataInput ,
136+ } from " ../../services/my-service/schemas" ;
88137import { router , publicProcedure } from " ../trpc" ;
89- import { get } from " @main/di " ;
90- import { MAIN_TOKENS } from " @main/di/tokens " ;
138+
139+ const getService = () => container . get < MyService >( MAIN_TOKENS . MyService ) ;
91140
92141export const myRouter = router ({
93- // Query - for read operations
94142 getData: publicProcedure
95- .input (z .object ({ id: z .string () }))
96- .query (async ({ input }) => {
97- const service = get <MyService >(MAIN_TOKENS .MyService );
98- return service .getData (input .id );
99- }),
143+ .input (getDataInput )
144+ .output (getDataOutput )
145+ .query (({ input }) => getService ().getData (input .id )),
100146
101- // Mutation - for write operations
102147 updateData: publicProcedure
103- .input (z .object ({ id: z .string (), value: z .string () }))
104- .mutation (async ({ input }) => {
105- const service = get <MyService >(MAIN_TOKENS .MyService );
106- return service .updateData (input .id , input .value );
107- }),
148+ .input (updateDataInput )
149+ .mutation (({ input }) => getService ().updateData (input .id , input .value )),
108150});
109151```
110152
@@ -199,12 +241,64 @@ Main services should be:
199241src/main/services/
200242├── my-service/
201243│ ├── service.ts # The injectable service class
202- │ └── types.ts # Types and interfaces
244+ │ ├── schemas.ts # Zod schemas for tRPC input/output
245+ │ └── types.ts # Internal types (not exposed via tRPC)
203246
204247src/renderer/services/
205248├── my-service.ts # Renderer-side service
206249```
207250
251+ ### Zod Schemas
252+
253+ All tRPC inputs and outputs use Zod schemas as the single source of truth. Types are inferred from schemas.
254+
255+ ``` typescript
256+ // src/main/services/my-service/schemas.ts
257+ import { z } from " zod" ;
258+
259+ export const getDataInput = z .object ({
260+ id: z .string (),
261+ });
262+
263+ export const getDataOutput = z .object ({
264+ id: z .string (),
265+ name: z .string (),
266+ createdAt: z .string (),
267+ });
268+
269+ export type GetDataInput = z .infer <typeof getDataInput >;
270+ export type GetDataOutput = z .infer <typeof getDataOutput >;
271+ ```
272+
273+ ``` typescript
274+ // src/main/trpc/routers/my-router.ts
275+ import { getDataInput , getDataOutput } from " ../../services/my-service/schemas" ;
276+
277+ export const myRouter = router ({
278+ getData: publicProcedure
279+ .input (getDataInput )
280+ .output (getDataOutput )
281+ .query (({ input }) => getService ().getData (input .id )),
282+ });
283+ ```
284+
285+ ``` typescript
286+ // src/main/services/my-service/service.ts
287+ import type { GetDataInput , GetDataOutput } from " ./schemas" ;
288+
289+ @injectable ()
290+ export class MyService {
291+ async getData(id : string ): Promise <GetDataOutput > {
292+ // ...
293+ }
294+ }
295+ ```
296+
297+ This pattern provides:
298+ - Runtime validation of inputs and outputs
299+ - Single source of truth for types
300+ - Explicit API contracts between main and renderer
301+
208302## Adding a New Feature
209303
2103041 . ** Create the service** in ` src/main/services/ `
@@ -214,6 +308,127 @@ src/renderer/services/
2143085 . ** Add router** to ` src/main/trpc/router.ts `
2153096 . ** Use in renderer** via ` trpcReact ` hooks
216310
311+ ## Events (tRPC Subscriptions)
312+
313+ For pushing real-time updates from main to renderer, use tRPC subscriptions with typed event emitters.
314+
315+ ### 1. Define Events in schemas.ts
316+
317+ Use a const object for event names and an interface for payloads:
318+
319+ ``` typescript
320+ // src/main/services/my-service/schemas.ts
321+ export const MyServiceEvent = {
322+ ItemCreated: " item-created" ,
323+ ItemDeleted: " item-deleted" ,
324+ } as const ;
325+
326+ export interface MyServiceEvents {
327+ [MyServiceEvent .ItemCreated ]: { id: string ; name: string };
328+ [MyServiceEvent .ItemDeleted ]: { id: string };
329+ }
330+ ```
331+
332+ ### 2. Extend TypedEventEmitter in Service
333+
334+ ``` typescript
335+ // src/main/services/my-service/service.ts
336+ import { TypedEventEmitter } from " ../../lib/typed-event-emitter" ;
337+ import { MyServiceEvent , type MyServiceEvents } from " ./schemas" ;
338+
339+ @injectable ()
340+ export class MyService extends TypedEventEmitter <MyServiceEvents > {
341+ async createItem(name : string ) {
342+ const item = { id: " 123" , name };
343+ // TypeScript enforces correct event name and payload shape
344+ this .emit (MyServiceEvent .ItemCreated , item );
345+ return item ;
346+ }
347+ }
348+ ```
349+
350+ ### 3. Create Subscriptions in Router
351+
352+ Use a helper to reduce boilerplate. For global events (broadcast to all subscribers):
353+
354+ ``` typescript
355+ // src/main/trpc/routers/my-router.ts
356+ import { on } from " node:events" ;
357+ import { MyServiceEvent , type MyServiceEvents } from " ../../services/my-service/schemas" ;
358+
359+ function subscribe<K extends keyof MyServiceEvents >(event : K ) {
360+ return publicProcedure .subscription (async function * (opts ) {
361+ const service = getService ();
362+ for await (const [payload] of on (service , event , { signal: opts .signal })) {
363+ yield payload as MyServiceEvents [K ];
364+ }
365+ });
366+ }
367+
368+ export const myRouter = router ({
369+ // ... queries and mutations
370+ onItemCreated: subscribe (MyServiceEvent .ItemCreated ),
371+ onItemDeleted: subscribe (MyServiceEvent .ItemDeleted ),
372+ });
373+ ```
374+
375+ For per-instance events (e.g., shell sessions), filter by an identifier:
376+
377+ ``` typescript
378+ // Events include an identifier to filter on
379+ export interface ShellEvents {
380+ [ShellEvent .Data ]: { sessionId: string ; data: string };
381+ [ShellEvent .Exit ]: { sessionId: string ; exitCode: number };
382+ }
383+
384+ // Router filters events to the specific session
385+ function subscribeToSession<K extends keyof ShellEvents >(event : K ) {
386+ return publicProcedure
387+ .input (sessionIdInput )
388+ .subscription (async function * (opts ) {
389+ const service = getService ();
390+ const targetSessionId = opts .input .sessionId ;
391+
392+ for await (const [payload] of on (service , event , { signal: opts .signal })) {
393+ const data = payload as ShellEvents [K ];
394+ if (data .sessionId === targetSessionId ) {
395+ yield data ;
396+ }
397+ }
398+ });
399+ }
400+
401+ export const shellRouter = router ({
402+ onData: subscribeToSession (ShellEvent .Data ),
403+ onExit: subscribeToSession (ShellEvent .Exit ),
404+ });
405+ ```
406+
407+ ### 4. Subscribe in Renderer
408+
409+ ``` typescript
410+ // React component - global events
411+ trpcReact .my .onItemCreated .useSubscription (undefined , {
412+ enabled: true ,
413+ onData : (item ) => {
414+ // item is typed as { id: string; name: string }
415+ console .log (" Created:" , item );
416+ },
417+ });
418+
419+ // React component - per-session events
420+ trpcReact .shell .onData .useSubscription (
421+ { sessionId },
422+ {
423+ enabled: !! sessionId ,
424+ onData : (event ) => {
425+ // event is typed as { sessionId: string; data: string }
426+ terminal .write (event .data );
427+ },
428+ },
429+ );
430+ ```
431+
217432## Code Style
218433
219434See [ CLAUDE.md] ( ./CLAUDE.md ) for linting, formatting, and import conventions.
0 commit comments