ts-procedures 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +96 -5
  2. package/agent_config/bin/postinstall.mjs +105 -0
  3. package/agent_config/bin/setup.mjs +286 -0
  4. package/agent_config/claude-code/.claude-plugin/plugin.json +5 -0
  5. package/agent_config/claude-code/agents/ts-procedures-architect.md +173 -0
  6. package/agent_config/claude-code/skills/guide/SKILL.md +142 -0
  7. package/agent_config/claude-code/skills/guide/anti-patterns.md +502 -0
  8. package/agent_config/claude-code/skills/guide/api-reference.md +550 -0
  9. package/agent_config/claude-code/skills/guide/patterns.md +572 -0
  10. package/agent_config/claude-code/skills/review/SKILL.md +53 -0
  11. package/agent_config/claude-code/skills/review/checklist.md +141 -0
  12. package/agent_config/claude-code/skills/scaffold/SKILL.md +54 -0
  13. package/agent_config/claude-code/skills/scaffold/templates/express-rpc.md +134 -0
  14. package/agent_config/claude-code/skills/scaffold/templates/hono-rpc.md +139 -0
  15. package/agent_config/claude-code/skills/scaffold/templates/hono-stream.md +134 -0
  16. package/agent_config/claude-code/skills/scaffold/templates/procedure.md +77 -0
  17. package/agent_config/claude-code/skills/scaffold/templates/stream-procedure.md +113 -0
  18. package/agent_config/copilot/copilot-instructions.md +255 -0
  19. package/agent_config/cursor/cursorrules +255 -0
  20. package/agent_config/lib/install-claude.mjs +109 -0
  21. package/build/implementations/http/express-rpc/index.js +13 -1
  22. package/build/implementations/http/express-rpc/index.js.map +1 -1
  23. package/build/implementations/http/hono-rpc/index.js +1 -1
  24. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  25. package/build/implementations/http/hono-stream/index.js +2 -2
  26. package/build/implementations/http/hono-stream/index.js.map +1 -1
  27. package/build/index.d.ts +1 -0
  28. package/build/index.js +7 -3
  29. package/build/index.js.map +1 -1
  30. package/build/index.test.js +77 -0
  31. package/build/index.test.js.map +1 -1
  32. package/package.json +7 -2
  33. package/src/implementations/http/README.md +12 -0
  34. package/src/implementations/http/express-rpc/README.md +27 -0
  35. package/src/implementations/http/express-rpc/index.ts +13 -1
  36. package/src/implementations/http/hono-rpc/README.md +25 -0
  37. package/src/implementations/http/hono-rpc/index.ts +1 -1
  38. package/src/implementations/http/hono-stream/README.md +17 -4
  39. package/src/implementations/http/hono-stream/index.ts +2 -2
  40. package/src/index.test.ts +95 -0
  41. package/src/index.ts +9 -3
@@ -91,6 +91,33 @@ builder.register(RPC, async (req) => {
91
91
  })
92
92
  ```
93
93
 
94
+ ## Abort Signal
95
+
96
+ `ExpressRPCAppBuilder` provides a lazy `AbortSignal` on `ctx.signal`. The underlying `AbortController` and `req.on('close')` listener are only created when `ctx.signal` is first accessed, so handlers that don't use it pay no overhead.
97
+
98
+ The signal aborts when the client disconnects before the response finishes (premature close). Normal response completion does not trigger an abort.
99
+
100
+ ```typescript
101
+ RPC.Create(
102
+ 'SlowQuery',
103
+ { scope: 'data', version: 1 },
104
+ async (ctx, params) => {
105
+ // Automatically cancelled if client disconnects
106
+ const response = await fetch('https://slow-api.example.com/data', {
107
+ signal: ctx.signal,
108
+ })
109
+ return response.json()
110
+ }
111
+ )
112
+ ```
113
+
114
+ To use `ctx.signal` with type safety, include `signal: AbortSignal` in your context type:
115
+
116
+ ```typescript
117
+ type AppContext = { userId: string; signal: AbortSignal }
118
+ const RPC = Procedures<AppContext, RPCConfig>()
119
+ ```
120
+
94
121
  ## Error Handling
95
122
 
96
123
  Custom error handler receives the procedure, request, response, and error:
@@ -179,7 +179,19 @@ export class ExpressRPCAppBuilder {
179
179
  ? await factoryContext(req)
180
180
  : (factoryContext as ExtractContext<typeof factory>)
181
181
 
182
- res.json(await procedure.handler(context, req.body))
182
+ let ac: AbortController | undefined
183
+ const ctxWithSignal = Object.defineProperty({ ...context }, 'signal', {
184
+ get() {
185
+ if (!ac) {
186
+ ac = new AbortController()
187
+ req.on('close', () => { if (!res.writableFinished) ac!.abort() })
188
+ }
189
+ return ac.signal
190
+ },
191
+ enumerable: true,
192
+ })
193
+
194
+ res.json(await procedure.handler(ctxWithSignal, req.body))
183
195
  if (this.config?.onSuccess) {
184
196
  this.config.onSuccess(procedure, req, res)
185
197
  }
@@ -135,6 +135,31 @@ The `extendProcedureDoc` callback receives:
135
135
 
136
136
  This allows you to derive documentation fields from procedure config or add static metadata per factory registration.
137
137
 
138
+ ## Abort Signal
139
+
140
+ `HonoRPCAppBuilder` automatically injects the HTTP request's `AbortSignal` (`c.req.raw.signal`) into the handler context. When a client disconnects mid-request, `ctx.signal` aborts, cancelling any signal-aware operations:
141
+
142
+ ```typescript
143
+ RPC.Create(
144
+ 'SlowQuery',
145
+ { scope: 'data', version: 1 },
146
+ async (ctx, params) => {
147
+ // Automatically cancelled if client disconnects
148
+ const response = await fetch('https://slow-api.example.com/data', {
149
+ signal: ctx.signal,
150
+ })
151
+ return response.json()
152
+ }
153
+ )
154
+ ```
155
+
156
+ To use `ctx.signal` with type safety, include `signal: AbortSignal` in your context type:
157
+
158
+ ```typescript
159
+ type AppContext = { userId: string; signal: AbortSignal }
160
+ const RPC = Procedures<AppContext, RPCConfig>()
161
+ ```
162
+
138
163
  ## Error Handling
139
164
 
140
165
  Custom error handler receives the procedure, context, and error. **Must return a Response:**
@@ -169,7 +169,7 @@ export class HonoRPCAppBuilder {
169
169
 
170
170
  // Hono uses c.req.json() for body parsing
171
171
  const body = await c.req.json().catch(() => ({}))
172
- const result = await procedure.handler(context, body)
172
+ const result = await procedure.handler({ ...context, signal: c.req.raw.signal }, body)
173
173
 
174
174
  if (this.config?.onSuccess) {
175
175
  this.config.onSuccess(procedure, c)
@@ -446,13 +446,26 @@ RPC.CreateStream('WatchUser', config, async function* (ctx) {
446
446
 
447
447
  ## Client Disconnect Handling
448
448
 
449
- When a client disconnects, the stream's `onAbort` handler is triggered, which calls `generator.return()` to clean up. The `ctx.signal` in your handler will be aborted:
449
+ When a client disconnects, the stream's `onAbort` handler is triggered, which calls `generator.return()` to clean up. The `ctx.signal` in your handler will be aborted.
450
+
451
+ `HonoStreamAppBuilder` injects the HTTP request's `AbortSignal` (`c.req.raw.signal`) into the handler context. This is combined with the stream's internal `AbortController` via `AbortSignal.any()`, so `ctx.signal` aborts on either client disconnect or normal stream completion.
452
+
453
+ Use `signal.reason` to distinguish between the two:
450
454
 
451
455
  ```typescript
452
456
  RPC.CreateStream('LongStream', config, async function* (ctx) {
453
- while (!ctx.signal.aborted) {
454
- yield { tick: Date.now() }
455
- await new Promise((r) => setTimeout(r, 1000))
457
+ try {
458
+ while (!ctx.signal.aborted) {
459
+ yield { tick: Date.now() }
460
+ await new Promise((r) => setTimeout(r, 1000))
461
+ }
462
+ } finally {
463
+ if (ctx.signal.reason === 'stream-completed') {
464
+ // Generator finished normally
465
+ } else {
466
+ // Client disconnected — clean up resources
467
+ await releaseResources()
468
+ }
456
469
  }
457
470
  })
458
471
  ```
@@ -242,7 +242,7 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
242
242
  ) {
243
243
  return streamSSE(c, async (stream) => {
244
244
  // Pass isPrevalidated: true since we already validated params in createStreamHandler
245
- const generator = procedure.handler({ ...context, isPrevalidated: true }, params)
245
+ const generator = procedure.handler({ ...context, signal: c.req.raw.signal, isPrevalidated: true }, params)
246
246
 
247
247
  stream.onAbort(async () => {
248
248
  await generator.return(undefined)
@@ -311,7 +311,7 @@ export class HonoStreamAppBuilder<TErrorData = unknown> {
311
311
  ) {
312
312
  return streamText(c, async (stream) => {
313
313
  // Pass isPrevalidated: true since we already validated params in createStreamHandler
314
- const generator = procedure.handler({ ...context, isPrevalidated: true }, params)
314
+ const generator = procedure.handler({ ...context, signal: c.req.raw.signal, isPrevalidated: true }, params)
315
315
 
316
316
  stream.onAbort(async () => {
317
317
  await generator.return(undefined)
package/src/index.test.ts CHANGED
@@ -431,6 +431,61 @@ describe('Procedures', () => {
431
431
  expect(e.meta).toEqual({ code: 'ERR_001' })
432
432
  }
433
433
  })
434
+
435
+ test('Create passes through external signal from context', async () => {
436
+ const { Create } = Procedures<{ signal: AbortSignal }>()
437
+ const externalAc = new AbortController()
438
+ let capturedSignal: AbortSignal | null = null
439
+
440
+ const { WithSignal } = Create('WithSignal', {}, async (ctx) => {
441
+ capturedSignal = ctx.signal!
442
+ return 'done'
443
+ })
444
+
445
+ await WithSignal({ signal: externalAc.signal }, {})
446
+ expect(capturedSignal).toBe(externalAc.signal)
447
+ expect(capturedSignal!.aborted).toBe(false)
448
+ })
449
+
450
+ test('Create external signal reflects abort from caller', async () => {
451
+ const { Create } = Procedures<{ signal: AbortSignal }>()
452
+ const externalAc = new AbortController()
453
+ let capturedSignal: AbortSignal | null = null
454
+
455
+ const { AbortedSignal } = Create('AbortedSignal', {}, async (ctx) => {
456
+ capturedSignal = ctx.signal!
457
+ expect(ctx.signal!.aborted).toBe(true)
458
+ return 'done'
459
+ })
460
+
461
+ externalAc.abort()
462
+ await AbortedSignal({ signal: externalAc.signal }, {})
463
+ expect(capturedSignal!.aborted).toBe(true)
464
+ })
465
+
466
+ test('Create external signal cancels in-flight async work', async () => {
467
+ const { Create } = Procedures<{ signal: AbortSignal }>()
468
+ const externalAc = new AbortController()
469
+ let wasAbortedDuringWork = false
470
+ const ready = Promise.withResolvers<void>()
471
+
472
+ const { LongWork } = Create('LongWork', {}, async (ctx) => {
473
+ ready.resolve()
474
+ await new Promise<void>((resolve) => {
475
+ ctx.signal!.addEventListener('abort', () => {
476
+ wasAbortedDuringWork = true
477
+ resolve()
478
+ })
479
+ })
480
+ return 'done'
481
+ })
482
+
483
+ const p = LongWork({ signal: externalAc.signal }, {})
484
+ await ready.promise
485
+ externalAc.abort()
486
+ await p
487
+ expect(wasAbortedDuringWork).toBe(true)
488
+ })
434
489
  })
435
490
 
436
491
  describe('Procedures - Definition Location in Errors', () => {
@@ -930,6 +985,46 @@ describe('Streaming Procedures - CreateStream', () => {
930
985
  expect(capturedSignal).not.toBeNull()
931
986
  expect(capturedSignal!.aborted).toBe(true)
932
987
  })
988
+
989
+ test('CreateStream signal.reason is stream-completed after normal completion', async () => {
990
+ const { CreateStream } = Procedures()
991
+ let capturedSignal: AbortSignal | null = null
992
+
993
+ const { ReasonStream } = CreateStream('ReasonStream', {}, async function* (ctx) {
994
+ capturedSignal = ctx.signal
995
+ yield 'value'
996
+ })
997
+
998
+ for await (const _val of ReasonStream({}, {})) {
999
+ // consume
1000
+ }
1001
+
1002
+ expect(capturedSignal!.reason).toBe('stream-completed')
1003
+ })
1004
+
1005
+ test('CreateStream combines external signal with internal signal', async () => {
1006
+ const { CreateStream } = Procedures<{ signal: AbortSignal }>()
1007
+ const externalAc = new AbortController()
1008
+ let capturedSignal: AbortSignal | null = null
1009
+
1010
+ const { CombinedStream } = CreateStream('CombinedStream', {}, async function* (ctx) {
1011
+ capturedSignal = ctx.signal
1012
+ // Combined signal is a new object, not the raw external signal
1013
+ expect(ctx.signal).not.toBe(externalAc.signal)
1014
+ yield 'value'
1015
+ })
1016
+
1017
+ // Abort external before consuming — combined signal should reflect it
1018
+ externalAc.abort('client-disconnected')
1019
+
1020
+ for await (const _val of CombinedStream({ signal: externalAc.signal }, {})) {
1021
+ // consume
1022
+ }
1023
+
1024
+ expect(capturedSignal!.aborted).toBe(true)
1025
+ // Reason comes from external abort, not internal 'stream-completed'
1026
+ expect(capturedSignal!.reason).toBe('client-disconnected')
1027
+ })
933
1028
  })
934
1029
 
935
1030
  describe('isPrevalidated context property', () => {
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export type TNoContextProvided = unknown
11
11
 
12
12
  export type TLocalContext = {
13
13
  error: (message: string, meta?: object) => ProcedureError
14
+ signal?: AbortSignal
14
15
  }
15
16
 
16
17
  export type TStreamContext = TLocalContext & {
@@ -292,9 +293,15 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
292
293
  }
293
294
  }
294
295
 
296
+ // Combine with external signal (e.g., from HTTP request) if provided
297
+ const incomingSignal = (ctx as { signal?: AbortSignal }).signal
298
+ const signal = incomingSignal
299
+ ? AbortSignal.any([incomingSignal, abortController.signal])
300
+ : abortController.signal
301
+
295
302
  const streamCtx: TStreamContext = {
296
303
  error: errorFactory,
297
- signal: abortController.signal,
304
+ signal,
298
305
  }
299
306
 
300
307
  const userGenerator = handler(
@@ -344,8 +351,7 @@ export function Procedures<TContext = TNoContextProvided, TExtendedConfig = unkn
344
351
  throw err
345
352
  }
346
353
  } finally {
347
- // Signal abort on early termination
348
- abortController.abort()
354
+ abortController.abort('stream-completed')
349
355
  }
350
356
  } as (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>,
351
357
  }