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.
- package/README.md +96 -5
- package/agent_config/bin/postinstall.mjs +105 -0
- package/agent_config/bin/setup.mjs +286 -0
- package/agent_config/claude-code/.claude-plugin/plugin.json +5 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +173 -0
- package/agent_config/claude-code/skills/guide/SKILL.md +142 -0
- package/agent_config/claude-code/skills/guide/anti-patterns.md +502 -0
- package/agent_config/claude-code/skills/guide/api-reference.md +550 -0
- package/agent_config/claude-code/skills/guide/patterns.md +572 -0
- package/agent_config/claude-code/skills/review/SKILL.md +53 -0
- package/agent_config/claude-code/skills/review/checklist.md +141 -0
- package/agent_config/claude-code/skills/scaffold/SKILL.md +54 -0
- package/agent_config/claude-code/skills/scaffold/templates/express-rpc.md +134 -0
- package/agent_config/claude-code/skills/scaffold/templates/hono-rpc.md +139 -0
- package/agent_config/claude-code/skills/scaffold/templates/hono-stream.md +134 -0
- package/agent_config/claude-code/skills/scaffold/templates/procedure.md +77 -0
- package/agent_config/claude-code/skills/scaffold/templates/stream-procedure.md +113 -0
- package/agent_config/copilot/copilot-instructions.md +255 -0
- package/agent_config/cursor/cursorrules +255 -0
- package/agent_config/lib/install-claude.mjs +109 -0
- package/build/implementations/http/express-rpc/index.js +13 -1
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/index.js +1 -1
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.js +2 -2
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.js +7 -3
- package/build/index.js.map +1 -1
- package/build/index.test.js +77 -0
- package/build/index.test.js.map +1 -1
- package/package.json +7 -2
- package/src/implementations/http/README.md +12 -0
- package/src/implementations/http/express-rpc/README.md +27 -0
- package/src/implementations/http/express-rpc/index.ts +13 -1
- package/src/implementations/http/hono-rpc/README.md +25 -0
- package/src/implementations/http/hono-rpc/index.ts +1 -1
- package/src/implementations/http/hono-stream/README.md +17 -4
- package/src/implementations/http/hono-stream/index.ts +2 -2
- package/src/index.test.ts +95 -0
- 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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
abortController.abort()
|
|
354
|
+
abortController.abort('stream-completed')
|
|
349
355
|
}
|
|
350
356
|
} as (ctx: TContext, params?: any) => AsyncGenerator<any, any, unknown>,
|
|
351
357
|
}
|