ts-procedures 5.2.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 +60 -0
- 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/package.json +7 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Stream Procedure Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## Implementation — `{{Name}}.stream.ts`
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Procedures } from 'ts-procedures'
|
|
7
|
+
import { Type } from 'typebox'
|
|
8
|
+
|
|
9
|
+
type AppContext = {
|
|
10
|
+
userId: string
|
|
11
|
+
signal?: AbortSignal
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { CreateStream } = Procedures<AppContext>()
|
|
15
|
+
|
|
16
|
+
export const { {{Name}}, procedure: {{Name}}Procedure, info: {{Name}}Info } = CreateStream(
|
|
17
|
+
'{{Name}}',
|
|
18
|
+
{
|
|
19
|
+
description: 'TODO: describe what {{Name}} streams',
|
|
20
|
+
schema: {
|
|
21
|
+
params: Type.Object({
|
|
22
|
+
// TODO: define input parameters
|
|
23
|
+
channel: Type.String(),
|
|
24
|
+
}),
|
|
25
|
+
yieldType: Type.Object({
|
|
26
|
+
// TODO: define shape of each yielded value
|
|
27
|
+
id: Type.String(),
|
|
28
|
+
data: Type.Any(),
|
|
29
|
+
timestamp: Type.Number(),
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
// Set to true to validate each yielded value against yieldType schema
|
|
33
|
+
// validateYields: false,
|
|
34
|
+
},
|
|
35
|
+
async function* (ctx, params) {
|
|
36
|
+
// ctx.signal is ALWAYS present in stream handlers (guaranteed AbortSignal)
|
|
37
|
+
|
|
38
|
+
// TODO: implement streaming logic
|
|
39
|
+
// Example: poll-based streaming
|
|
40
|
+
let counter = 0
|
|
41
|
+
while (!ctx.signal.aborted) {
|
|
42
|
+
const data = await fetchLatestData(params.channel, { signal: ctx.signal })
|
|
43
|
+
|
|
44
|
+
yield {
|
|
45
|
+
id: String(counter++),
|
|
46
|
+
data,
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Wait between polls (pass signal for cancellation)
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
const timeout = setTimeout(resolve, 1000)
|
|
53
|
+
ctx.signal.addEventListener('abort', () => {
|
|
54
|
+
clearTimeout(timeout)
|
|
55
|
+
resolve(undefined)
|
|
56
|
+
}, { once: true })
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// After loop exits, signal.reason tells you why:
|
|
61
|
+
// - 'stream-completed': consumer finished reading (normal)
|
|
62
|
+
// - other: client disconnected or external abort
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Placeholder — replace with real data fetching
|
|
67
|
+
async function fetchLatestData(channel: string, opts?: { signal?: AbortSignal }) {
|
|
68
|
+
return { value: Math.random() }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Test — `{{Name}}.stream.test.ts`
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { describe, test, expect } from 'vitest'
|
|
76
|
+
import { ProcedureValidationError } from 'ts-procedures'
|
|
77
|
+
import { {{Name}} } from './{{Name}}.stream'
|
|
78
|
+
|
|
79
|
+
const mockContext = { userId: 'test-user' }
|
|
80
|
+
|
|
81
|
+
describe('{{Name}}', () => {
|
|
82
|
+
test('yields expected values', async () => {
|
|
83
|
+
const values = []
|
|
84
|
+
for await (const val of {{Name}}(mockContext, { channel: 'test' })) {
|
|
85
|
+
values.push(val)
|
|
86
|
+
if (values.length >= 3) break // Don't consume indefinitely
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
expect(values).toHaveLength(3)
|
|
90
|
+
expect(values[0]).toHaveProperty('id')
|
|
91
|
+
expect(values[0]).toHaveProperty('data')
|
|
92
|
+
expect(values[0]).toHaveProperty('timestamp')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('throws ProcedureValidationError for invalid params', async () => {
|
|
96
|
+
const gen = {{Name}}(mockContext, {} as any)
|
|
97
|
+
await expect(gen.next()).rejects.toThrow(ProcedureValidationError)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('respects signal abort', async () => {
|
|
101
|
+
const ac = new AbortController()
|
|
102
|
+
const values = []
|
|
103
|
+
|
|
104
|
+
setTimeout(() => ac.abort(), 100)
|
|
105
|
+
|
|
106
|
+
for await (const val of {{Name}}({ ...mockContext, signal: ac.signal }, { channel: 'test' })) {
|
|
107
|
+
values.push(val)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
expect(values.length).toBeGreaterThanOrEqual(0)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
```
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# ts-procedures Framework Reference
|
|
2
|
+
|
|
3
|
+
You are assisting a developer using **ts-procedures**, a TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition.
|
|
4
|
+
|
|
5
|
+
## Core Flow
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Procedures<TContext, TExtendedConfig>(builder?)
|
|
9
|
+
↓
|
|
10
|
+
Create(name, config, handler) → Standard async procedure
|
|
11
|
+
CreateStream(name, config, handler) → Streaming async generator
|
|
12
|
+
↓
|
|
13
|
+
Returns: { [name]: handler, procedure: handler, info: metadata }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Imports
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
// Core
|
|
20
|
+
import { Procedures } from 'ts-procedures'
|
|
21
|
+
import type { TLocalContext, TStreamContext, TProcedureRegistration, TStreamProcedureRegistration } from 'ts-procedures'
|
|
22
|
+
|
|
23
|
+
// Errors
|
|
24
|
+
import { ProcedureError, ProcedureValidationError, ProcedureYieldValidationError, ProcedureRegistrationError } from 'ts-procedures'
|
|
25
|
+
|
|
26
|
+
// HTTP types
|
|
27
|
+
import type { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode } from 'ts-procedures/http'
|
|
28
|
+
|
|
29
|
+
// Express RPC
|
|
30
|
+
import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
|
|
31
|
+
|
|
32
|
+
// Hono RPC
|
|
33
|
+
import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
|
|
34
|
+
|
|
35
|
+
// Hono Streaming
|
|
36
|
+
import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Architecture Rules
|
|
40
|
+
|
|
41
|
+
1. **schema.params is validated at runtime; schema.returnType is documentation only.** Never expect returnType to be validated. Use TypeScript for return type safety.
|
|
42
|
+
|
|
43
|
+
2. **Use ctx.error() for business logic errors.** Never throw raw `Error` instances. `ctx.error(message, meta?)` creates `ProcedureError` with procedure name, metadata, and enhanced stack trace.
|
|
44
|
+
|
|
45
|
+
3. **Pass ctx.signal to all downstream async calls.** HTTP implementations inject AbortSignal automatically. Pass it to fetch, database queries, and other async operations for cancellation support.
|
|
46
|
+
|
|
47
|
+
4. **Stream handlers always have ctx.signal (guaranteed AbortSignal).** Standard handlers get it when HTTP implementations provide it. Check `signal.reason === 'stream-completed'` to distinguish normal completion from client disconnect.
|
|
48
|
+
|
|
49
|
+
5. **Use TypeBox for schemas** (`import { Type } from 'typebox'`). Plain JSON Schema objects are not recognized. AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
|
|
50
|
+
|
|
51
|
+
6. **One factory per access level.** Separate `Procedures()` factories for public, authenticated, admin contexts.
|
|
52
|
+
|
|
53
|
+
7. **onCreate callback enables framework integration.** Use for route registration, OpenAPI generation, logging.
|
|
54
|
+
|
|
55
|
+
8. **getProcedures() for introspection.** Returns all registered procedures with metadata.
|
|
56
|
+
|
|
57
|
+
## Procedure Pattern
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { Procedures } from 'ts-procedures'
|
|
61
|
+
import { Type } from 'typebox'
|
|
62
|
+
|
|
63
|
+
type AppContext = { userId: string; signal?: AbortSignal }
|
|
64
|
+
|
|
65
|
+
const { Create, CreateStream } = Procedures<AppContext>()
|
|
66
|
+
|
|
67
|
+
// Standard procedure
|
|
68
|
+
const { GetUser } = Create(
|
|
69
|
+
'GetUser',
|
|
70
|
+
{
|
|
71
|
+
description: 'Fetch user by ID',
|
|
72
|
+
schema: {
|
|
73
|
+
params: Type.Object({ id: Type.String() }),
|
|
74
|
+
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
async (ctx, params) => {
|
|
78
|
+
// params.id guaranteed string (AJV validated)
|
|
79
|
+
const user = await fetchUser(params.id, { signal: ctx.signal })
|
|
80
|
+
if (!user) throw ctx.error('Not found', { code: 'NOT_FOUND' })
|
|
81
|
+
return user
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Stream Procedure Pattern
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
const { StreamEvents } = CreateStream(
|
|
90
|
+
'StreamEvents',
|
|
91
|
+
{
|
|
92
|
+
schema: {
|
|
93
|
+
params: Type.Object({ channel: Type.String() }),
|
|
94
|
+
yieldType: Type.Object({ type: Type.String(), data: Type.Any() }),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
async function* (ctx, params) {
|
|
98
|
+
// ctx.signal always present in streams
|
|
99
|
+
while (!ctx.signal.aborted) {
|
|
100
|
+
const event = await pollEvents(params.channel, { signal: ctx.signal })
|
|
101
|
+
yield event
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Express RPC Pattern
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
|
|
111
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
112
|
+
|
|
113
|
+
const RPC = Procedures<AppContext, RPCConfig>()
|
|
114
|
+
|
|
115
|
+
RPC.Create('GetUser', {
|
|
116
|
+
scope: 'users', version: 1,
|
|
117
|
+
schema: { params: Type.Object({ id: Type.String() }) },
|
|
118
|
+
}, async (ctx, params) => fetchUser(params.id))
|
|
119
|
+
|
|
120
|
+
const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
|
|
121
|
+
.register(RPC, async (req) => ({ userId: await authenticate(req) }))
|
|
122
|
+
.build()
|
|
123
|
+
// POST /api/users/get-user/1
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Hono RPC Pattern
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
|
|
130
|
+
|
|
131
|
+
const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
|
|
132
|
+
.register(RPC, (c) => ({ userId: c.req.header('x-user-id') }))
|
|
133
|
+
.build()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Hono Streaming Pattern
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
|
|
140
|
+
|
|
141
|
+
const StreamRPC = Procedures<AppContext, RPCConfig>()
|
|
142
|
+
|
|
143
|
+
StreamRPC.CreateStream('Feed', {
|
|
144
|
+
scope: 'events', version: 1,
|
|
145
|
+
schema: { params: Type.Object({ channel: Type.String() }) },
|
|
146
|
+
}, async function* (ctx, params) {
|
|
147
|
+
while (!ctx.signal.aborted) {
|
|
148
|
+
const event = await poll({ signal: ctx.signal })
|
|
149
|
+
yield sse(event, { event: 'update' })
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const app = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
|
|
154
|
+
.register(StreamRPC, (c) => ({ userId: c.req.header('x-user-id') }))
|
|
155
|
+
.build()
|
|
156
|
+
// GET|POST /events/feed/1
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Error Handling
|
|
160
|
+
|
|
161
|
+
| Error Class | Trigger | HTTP Status |
|
|
162
|
+
|-------------|---------|-------------|
|
|
163
|
+
| `ProcedureValidationError` | Schema params validation failure | 400 |
|
|
164
|
+
| `ProcedureError` | `ctx.error()` or unhandled handler exception | 422/500 |
|
|
165
|
+
| `ProcedureYieldValidationError` | Yield validation failure (validateYields: true) | N/A (mid-stream) |
|
|
166
|
+
| `ProcedureRegistrationError` | Invalid schema or duplicate name at registration | N/A (startup) |
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// In HTTP builder
|
|
170
|
+
onError: (procedure, req, res, error) => {
|
|
171
|
+
if (error instanceof ProcedureValidationError) {
|
|
172
|
+
res.status(400).json({ error: error.message, details: error.errors })
|
|
173
|
+
} else if (error instanceof ProcedureError) {
|
|
174
|
+
res.status(422).json({ error: error.message, meta: error.meta })
|
|
175
|
+
} else {
|
|
176
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Lifecycle Hook Order
|
|
182
|
+
|
|
183
|
+
### Standard RPC
|
|
184
|
+
```
|
|
185
|
+
onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
|
|
186
|
+
→ onError → onRequestEnd
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Streaming
|
|
190
|
+
```
|
|
191
|
+
onRequestStart → factoryContext() → validation → onStreamStart → handler yields → onStreamEnd → onRequestEnd
|
|
192
|
+
→ onPreStreamError → onRequestEnd
|
|
193
|
+
→ onMidStreamError → onStreamEnd → onRequestEnd
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Decision Framework
|
|
197
|
+
|
|
198
|
+
**Which procedure type?**
|
|
199
|
+
- Single response → `Create`
|
|
200
|
+
- Multiple values over time → `CreateStream`
|
|
201
|
+
|
|
202
|
+
**Which schema library?**
|
|
203
|
+
- **TypeBox** (`import { Type } from 'typebox'`)
|
|
204
|
+
|
|
205
|
+
**Which HTTP implementation?**
|
|
206
|
+
- Express → `ExpressRPCAppBuilder`
|
|
207
|
+
- Hono (standard) → `HonoRPCAppBuilder`
|
|
208
|
+
- Hono (streaming) → `HonoStreamAppBuilder`
|
|
209
|
+
|
|
210
|
+
**Stream mode?**
|
|
211
|
+
- Browser EventSource → `'sse'` (default)
|
|
212
|
+
- Simple HTTP client → `'text'` (newline-delimited JSON)
|
|
213
|
+
|
|
214
|
+
## Anti-Patterns — NEVER Do These
|
|
215
|
+
|
|
216
|
+
1. **Never throw raw Error** — use `ctx.error(message, meta?)`
|
|
217
|
+
2. **Never expect returnType runtime validation** — it's docs only
|
|
218
|
+
3. **Never put validation logic in handler** — use `schema.params`
|
|
219
|
+
4. **Never ignore ctx.signal** — pass to all async calls
|
|
220
|
+
5. **Never skip signal.aborted check in stream loops** — causes resource leaks
|
|
221
|
+
6. **Never register Create procedures with HonoStreamAppBuilder** — they're silently ignored
|
|
222
|
+
7. **Never use plain JSON Schema objects** — use TypeBox builders
|
|
223
|
+
8. **Never swallow errors without re-throwing** — hides failures
|
|
224
|
+
9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
|
|
225
|
+
10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
|
|
226
|
+
|
|
227
|
+
## Testing
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { describe, test, expect } from 'vitest'
|
|
231
|
+
import { ProcedureError, ProcedureValidationError } from 'ts-procedures'
|
|
232
|
+
|
|
233
|
+
describe('GetUser', () => {
|
|
234
|
+
test('valid params', async () => {
|
|
235
|
+
const result = await GetUser(mockCtx, { id: 'user-1' })
|
|
236
|
+
expect(result.id).toBe('user-1')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('invalid params', async () => {
|
|
240
|
+
await expect(GetUser(mockCtx, {})).rejects.toThrow(ProcedureValidationError)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('business error', async () => {
|
|
244
|
+
await expect(GetUser(mockCtx, { id: 'missing' })).rejects.toThrow(ProcedureError)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## HTTP Route Path Format
|
|
250
|
+
|
|
251
|
+
`{pathPrefix}/{scope}/{kebab-case-name}/{version}`
|
|
252
|
+
|
|
253
|
+
- `scope` can be string or string[] (joined as path segments)
|
|
254
|
+
- Procedure name auto-converted to kebab-case
|
|
255
|
+
- Stream routes support both GET (query params) and POST (JSON body)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# ts-procedures Framework Reference
|
|
2
|
+
|
|
3
|
+
You are assisting a developer using **ts-procedures**, a TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition.
|
|
4
|
+
|
|
5
|
+
## Core Flow
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Procedures<TContext, TExtendedConfig>(builder?)
|
|
9
|
+
↓
|
|
10
|
+
Create(name, config, handler) → Standard async procedure
|
|
11
|
+
CreateStream(name, config, handler) → Streaming async generator
|
|
12
|
+
↓
|
|
13
|
+
Returns: { [name]: handler, procedure: handler, info: metadata }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Imports
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
// Core
|
|
20
|
+
import { Procedures } from 'ts-procedures'
|
|
21
|
+
import type { TLocalContext, TStreamContext, TProcedureRegistration, TStreamProcedureRegistration } from 'ts-procedures'
|
|
22
|
+
|
|
23
|
+
// Errors
|
|
24
|
+
import { ProcedureError, ProcedureValidationError, ProcedureYieldValidationError, ProcedureRegistrationError } from 'ts-procedures'
|
|
25
|
+
|
|
26
|
+
// HTTP types
|
|
27
|
+
import type { RPCConfig, RPCHttpRouteDoc, StreamHttpRouteDoc, StreamMode } from 'ts-procedures/http'
|
|
28
|
+
|
|
29
|
+
// Express RPC
|
|
30
|
+
import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
|
|
31
|
+
|
|
32
|
+
// Hono RPC
|
|
33
|
+
import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
|
|
34
|
+
|
|
35
|
+
// Hono Streaming
|
|
36
|
+
import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Architecture Rules
|
|
40
|
+
|
|
41
|
+
1. **schema.params is validated at runtime; schema.returnType is documentation only.** Never expect returnType to be validated. Use TypeScript for return type safety.
|
|
42
|
+
|
|
43
|
+
2. **Use ctx.error() for business logic errors.** Never throw raw `Error` instances. `ctx.error(message, meta?)` creates `ProcedureError` with procedure name, metadata, and enhanced stack trace.
|
|
44
|
+
|
|
45
|
+
3. **Pass ctx.signal to all downstream async calls.** HTTP implementations inject AbortSignal automatically. Pass it to fetch, database queries, and other async operations for cancellation support.
|
|
46
|
+
|
|
47
|
+
4. **Stream handlers always have ctx.signal (guaranteed AbortSignal).** Standard handlers get it when HTTP implementations provide it. Check `signal.reason === 'stream-completed'` to distinguish normal completion from client disconnect.
|
|
48
|
+
|
|
49
|
+
5. **Use TypeBox for schemas** (`import { Type } from 'typebox'`). Plain JSON Schema objects are not recognized. AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
|
|
50
|
+
|
|
51
|
+
6. **One factory per access level.** Separate `Procedures()` factories for public, authenticated, admin contexts.
|
|
52
|
+
|
|
53
|
+
7. **onCreate callback enables framework integration.** Use for route registration, OpenAPI generation, logging.
|
|
54
|
+
|
|
55
|
+
8. **getProcedures() for introspection.** Returns all registered procedures with metadata.
|
|
56
|
+
|
|
57
|
+
## Procedure Pattern
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { Procedures } from 'ts-procedures'
|
|
61
|
+
import { Type } from 'typebox'
|
|
62
|
+
|
|
63
|
+
type AppContext = { userId: string; signal?: AbortSignal }
|
|
64
|
+
|
|
65
|
+
const { Create, CreateStream } = Procedures<AppContext>()
|
|
66
|
+
|
|
67
|
+
// Standard procedure
|
|
68
|
+
const { GetUser } = Create(
|
|
69
|
+
'GetUser',
|
|
70
|
+
{
|
|
71
|
+
description: 'Fetch user by ID',
|
|
72
|
+
schema: {
|
|
73
|
+
params: Type.Object({ id: Type.String() }),
|
|
74
|
+
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
async (ctx, params) => {
|
|
78
|
+
// params.id guaranteed string (AJV validated)
|
|
79
|
+
const user = await fetchUser(params.id, { signal: ctx.signal })
|
|
80
|
+
if (!user) throw ctx.error('Not found', { code: 'NOT_FOUND' })
|
|
81
|
+
return user
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Stream Procedure Pattern
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
const { StreamEvents } = CreateStream(
|
|
90
|
+
'StreamEvents',
|
|
91
|
+
{
|
|
92
|
+
schema: {
|
|
93
|
+
params: Type.Object({ channel: Type.String() }),
|
|
94
|
+
yieldType: Type.Object({ type: Type.String(), data: Type.Any() }),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
async function* (ctx, params) {
|
|
98
|
+
// ctx.signal always present in streams
|
|
99
|
+
while (!ctx.signal.aborted) {
|
|
100
|
+
const event = await pollEvents(params.channel, { signal: ctx.signal })
|
|
101
|
+
yield event
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Express RPC Pattern
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
|
|
111
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
112
|
+
|
|
113
|
+
const RPC = Procedures<AppContext, RPCConfig>()
|
|
114
|
+
|
|
115
|
+
RPC.Create('GetUser', {
|
|
116
|
+
scope: 'users', version: 1,
|
|
117
|
+
schema: { params: Type.Object({ id: Type.String() }) },
|
|
118
|
+
}, async (ctx, params) => fetchUser(params.id))
|
|
119
|
+
|
|
120
|
+
const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
|
|
121
|
+
.register(RPC, async (req) => ({ userId: await authenticate(req) }))
|
|
122
|
+
.build()
|
|
123
|
+
// POST /api/users/get-user/1
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Hono RPC Pattern
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
|
|
130
|
+
|
|
131
|
+
const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
|
|
132
|
+
.register(RPC, (c) => ({ userId: c.req.header('x-user-id') }))
|
|
133
|
+
.build()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Hono Streaming Pattern
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
|
|
140
|
+
|
|
141
|
+
const StreamRPC = Procedures<AppContext, RPCConfig>()
|
|
142
|
+
|
|
143
|
+
StreamRPC.CreateStream('Feed', {
|
|
144
|
+
scope: 'events', version: 1,
|
|
145
|
+
schema: { params: Type.Object({ channel: Type.String() }) },
|
|
146
|
+
}, async function* (ctx, params) {
|
|
147
|
+
while (!ctx.signal.aborted) {
|
|
148
|
+
const event = await poll({ signal: ctx.signal })
|
|
149
|
+
yield sse(event, { event: 'update' })
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const app = new HonoStreamAppBuilder({ defaultStreamMode: 'sse' })
|
|
154
|
+
.register(StreamRPC, (c) => ({ userId: c.req.header('x-user-id') }))
|
|
155
|
+
.build()
|
|
156
|
+
// GET|POST /events/feed/1
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Error Handling
|
|
160
|
+
|
|
161
|
+
| Error Class | Trigger | HTTP Status |
|
|
162
|
+
|-------------|---------|-------------|
|
|
163
|
+
| `ProcedureValidationError` | Schema params validation failure | 400 |
|
|
164
|
+
| `ProcedureError` | `ctx.error()` or unhandled handler exception | 422/500 |
|
|
165
|
+
| `ProcedureYieldValidationError` | Yield validation failure (validateYields: true) | N/A (mid-stream) |
|
|
166
|
+
| `ProcedureRegistrationError` | Invalid schema or duplicate name at registration | N/A (startup) |
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// In HTTP builder
|
|
170
|
+
onError: (procedure, req, res, error) => {
|
|
171
|
+
if (error instanceof ProcedureValidationError) {
|
|
172
|
+
res.status(400).json({ error: error.message, details: error.errors })
|
|
173
|
+
} else if (error instanceof ProcedureError) {
|
|
174
|
+
res.status(422).json({ error: error.message, meta: error.meta })
|
|
175
|
+
} else {
|
|
176
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Lifecycle Hook Order
|
|
182
|
+
|
|
183
|
+
### Standard RPC
|
|
184
|
+
```
|
|
185
|
+
onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
|
|
186
|
+
→ onError → onRequestEnd
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Streaming
|
|
190
|
+
```
|
|
191
|
+
onRequestStart → factoryContext() → validation → onStreamStart → handler yields → onStreamEnd → onRequestEnd
|
|
192
|
+
→ onPreStreamError → onRequestEnd
|
|
193
|
+
→ onMidStreamError → onStreamEnd → onRequestEnd
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Decision Framework
|
|
197
|
+
|
|
198
|
+
**Which procedure type?**
|
|
199
|
+
- Single response → `Create`
|
|
200
|
+
- Multiple values over time → `CreateStream`
|
|
201
|
+
|
|
202
|
+
**Which schema library?**
|
|
203
|
+
- **TypeBox** (`import { Type } from 'typebox'`)
|
|
204
|
+
|
|
205
|
+
**Which HTTP implementation?**
|
|
206
|
+
- Express → `ExpressRPCAppBuilder`
|
|
207
|
+
- Hono (standard) → `HonoRPCAppBuilder`
|
|
208
|
+
- Hono (streaming) → `HonoStreamAppBuilder`
|
|
209
|
+
|
|
210
|
+
**Stream mode?**
|
|
211
|
+
- Browser EventSource → `'sse'` (default)
|
|
212
|
+
- Simple HTTP client → `'text'` (newline-delimited JSON)
|
|
213
|
+
|
|
214
|
+
## Anti-Patterns — NEVER Do These
|
|
215
|
+
|
|
216
|
+
1. **Never throw raw Error** — use `ctx.error(message, meta?)`
|
|
217
|
+
2. **Never expect returnType runtime validation** — it's docs only
|
|
218
|
+
3. **Never put validation logic in handler** — use `schema.params`
|
|
219
|
+
4. **Never ignore ctx.signal** — pass to all async calls
|
|
220
|
+
5. **Never skip signal.aborted check in stream loops** — causes resource leaks
|
|
221
|
+
6. **Never register Create procedures with HonoStreamAppBuilder** — they're silently ignored
|
|
222
|
+
7. **Never use plain JSON Schema objects** — use TypeBox builders
|
|
223
|
+
8. **Never swallow errors without re-throwing** — hides failures
|
|
224
|
+
9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
|
|
225
|
+
10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
|
|
226
|
+
|
|
227
|
+
## Testing
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { describe, test, expect } from 'vitest'
|
|
231
|
+
import { ProcedureError, ProcedureValidationError } from 'ts-procedures'
|
|
232
|
+
|
|
233
|
+
describe('GetUser', () => {
|
|
234
|
+
test('valid params', async () => {
|
|
235
|
+
const result = await GetUser(mockCtx, { id: 'user-1' })
|
|
236
|
+
expect(result.id).toBe('user-1')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('invalid params', async () => {
|
|
240
|
+
await expect(GetUser(mockCtx, {})).rejects.toThrow(ProcedureValidationError)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('business error', async () => {
|
|
244
|
+
await expect(GetUser(mockCtx, { id: 'missing' })).rejects.toThrow(ProcedureError)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## HTTP Route Path Format
|
|
250
|
+
|
|
251
|
+
`{pathPrefix}/{scope}/{kebab-case-name}/{version}`
|
|
252
|
+
|
|
253
|
+
- `scope` can be string or string[] (joined as path segments)
|
|
254
|
+
- Procedure name auto-converted to kebab-case
|
|
255
|
+
- Stream routes support both GET (query params) and POST (JSON body)
|