ts-procedures 5.2.0 → 5.4.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 +150 -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 +188 -0
- package/agent_config/claude-code/skills/guide/SKILL.md +142 -0
- package/agent_config/claude-code/skills/guide/anti-patterns.md +608 -0
- package/agent_config/claude-code/skills/guide/api-reference.md +696 -0
- package/agent_config/claude-code/skills/guide/patterns.md +727 -0
- package/agent_config/claude-code/skills/review/SKILL.md +53 -0
- package/agent_config/claude-code/skills/review/checklist.md +163 -0
- package/agent_config/claude-code/skills/scaffold/SKILL.md +56 -0
- package/agent_config/claude-code/skills/scaffold/templates/express-rpc.md +134 -0
- package/agent_config/claude-code/skills/scaffold/templates/hono-api.md +169 -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 +290 -0
- package/agent_config/cursor/cursorrules +290 -0
- package/agent_config/lib/install-claude.mjs +109 -0
- package/build/implementations/http/hono-api/index.d.ts +102 -0
- package/build/implementations/http/hono-api/index.js +339 -0
- package/build/implementations/http/hono-api/index.js.map +1 -0
- package/build/implementations/http/hono-api/index.test.d.ts +1 -0
- package/build/implementations/http/hono-api/index.test.js +983 -0
- package/build/implementations/http/hono-api/index.test.js.map +1 -0
- package/build/implementations/http/hono-api/types.d.ts +13 -0
- package/build/implementations/http/hono-api/types.js +2 -0
- package/build/implementations/http/hono-api/types.js.map +1 -0
- package/build/implementations/types.d.ts +44 -0
- package/build/index.d.ts +28 -6
- package/build/index.js +28 -0
- package/build/index.js.map +1 -1
- package/build/schema/compute-schema.d.ts +5 -0
- package/build/schema/compute-schema.js +8 -1
- package/build/schema/compute-schema.js.map +1 -1
- package/build/schema/parser.d.ts +6 -5
- package/build/schema/parser.js +54 -0
- package/build/schema/parser.js.map +1 -1
- package/package.json +14 -4
- package/src/implementations/http/README.md +45 -2
- package/src/implementations/http/hono-api/index.test.ts +1328 -0
- package/src/implementations/http/hono-api/index.ts +461 -0
- package/src/implementations/http/hono-api/types.ts +16 -0
- package/src/implementations/types.ts +52 -0
- package/src/index.ts +87 -10
- package/src/schema/compute-schema.ts +23 -2
- package/src/schema/parser.ts +70 -3
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
# ts-procedures Prescribed Patterns
|
|
2
|
+
|
|
3
|
+
Follow these patterns exactly when writing ts-procedures code.
|
|
4
|
+
|
|
5
|
+
> All examples use `import { Type } from 'typebox'` for schema definitions. This import is assumed unless shown explicitly.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Basic Procedure Setup
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { Procedures } from 'ts-procedures'
|
|
13
|
+
|
|
14
|
+
// 1. Define context type
|
|
15
|
+
type AppContext = { userId: string; requestId: string }
|
|
16
|
+
|
|
17
|
+
// 2. Create factory (optionally with extended config)
|
|
18
|
+
const { Create, CreateStream, getProcedures } = Procedures<AppContext>()
|
|
19
|
+
|
|
20
|
+
// 3. Register procedures
|
|
21
|
+
const { GetUser, procedure, info } = Create(
|
|
22
|
+
'GetUser',
|
|
23
|
+
{ description: 'Fetch user by ID' },
|
|
24
|
+
async (ctx, params) => {
|
|
25
|
+
return { id: ctx.userId, name: 'John' }
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// 4. Call procedure
|
|
30
|
+
const result = await GetUser({ userId: 'u1', requestId: 'r1' }, {})
|
|
31
|
+
// or: await procedure({ userId: 'u1', requestId: 'r1' }, {})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Schema Validation with TypeBox
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { Type } from 'typebox'
|
|
40
|
+
|
|
41
|
+
const { Create } = Procedures<AppContext>()
|
|
42
|
+
|
|
43
|
+
const { GetUser } = Create(
|
|
44
|
+
'GetUser',
|
|
45
|
+
{
|
|
46
|
+
schema: {
|
|
47
|
+
params: Type.Object({
|
|
48
|
+
userId: Type.String(),
|
|
49
|
+
}),
|
|
50
|
+
returnType: Type.Object({
|
|
51
|
+
id: Type.String(),
|
|
52
|
+
name: Type.String(),
|
|
53
|
+
email: Type.String(),
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
async (ctx, params) => {
|
|
58
|
+
// params is typed as { userId: string }
|
|
59
|
+
// params.userId is guaranteed to be a string (validated by AJV)
|
|
60
|
+
const user = await fetchUser(params.userId)
|
|
61
|
+
return user
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Error Handling in Handlers
|
|
69
|
+
|
|
70
|
+
Use `ctx.error()` for business logic errors. Never throw raw Error instances.
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
const { TransferFunds } = Create(
|
|
74
|
+
'TransferFunds',
|
|
75
|
+
{
|
|
76
|
+
schema: {
|
|
77
|
+
params: Type.Object({
|
|
78
|
+
fromAccountId: Type.String(),
|
|
79
|
+
toAccountId: Type.String(),
|
|
80
|
+
amount: Type.Number(),
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
async (ctx, params) => {
|
|
85
|
+
const account = await getAccount(params.fromAccountId)
|
|
86
|
+
|
|
87
|
+
if (!account) {
|
|
88
|
+
throw ctx.error('Account not found', { code: 'NOT_FOUND', accountId: params.fromAccountId })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (account.balance < params.amount) {
|
|
92
|
+
throw ctx.error('Insufficient funds', {
|
|
93
|
+
code: 'INSUFFICIENT_FUNDS',
|
|
94
|
+
balance: account.balance,
|
|
95
|
+
requested: params.amount,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return await executeTransfer(params)
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Stream Procedures
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const { StreamNotifications } = CreateStream(
|
|
110
|
+
'StreamNotifications',
|
|
111
|
+
{
|
|
112
|
+
description: 'Real-time notification feed',
|
|
113
|
+
schema: {
|
|
114
|
+
params: Type.Object({
|
|
115
|
+
userId: Type.String(),
|
|
116
|
+
}),
|
|
117
|
+
yieldType: Type.Object({
|
|
118
|
+
id: Type.String(),
|
|
119
|
+
message: Type.String(),
|
|
120
|
+
timestamp: Type.Number(),
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
async function* (ctx, params) {
|
|
125
|
+
// ctx.signal is always present in stream handlers
|
|
126
|
+
const subscription = subscribeToNotifications(params.userId)
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
for await (const notification of subscription) {
|
|
130
|
+
if (ctx.signal.aborted) break
|
|
131
|
+
yield notification
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
subscription.close()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## AbortSignal in Stream Handlers
|
|
143
|
+
|
|
144
|
+
Always check `ctx.signal` for cancellation. Distinguish normal completion from client disconnect.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const { StreamData } = CreateStream(
|
|
148
|
+
'StreamData',
|
|
149
|
+
{},
|
|
150
|
+
async function* (ctx) {
|
|
151
|
+
try {
|
|
152
|
+
while (!ctx.signal.aborted) {
|
|
153
|
+
const data = await fetchLatestData({ signal: ctx.signal })
|
|
154
|
+
yield data
|
|
155
|
+
await delay(1000)
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
if (ctx.signal.reason === 'stream-completed') {
|
|
159
|
+
// Normal completion — consumer finished reading
|
|
160
|
+
console.log('Stream completed normally')
|
|
161
|
+
} else {
|
|
162
|
+
// Client disconnected or external abort
|
|
163
|
+
console.log('Stream aborted:', ctx.signal.reason)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## AbortSignal in Standard Handlers
|
|
173
|
+
|
|
174
|
+
Pass the signal to downstream async calls for automatic cancellation.
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const { LongOperation } = Create(
|
|
178
|
+
'LongOperation',
|
|
179
|
+
{},
|
|
180
|
+
async (ctx, params) => {
|
|
181
|
+
// ctx.signal is available when HTTP implementation provides it
|
|
182
|
+
const data = await fetch('https://api.example.com/data', {
|
|
183
|
+
signal: ctx.signal,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const processed = await processData(data, { signal: ctx.signal })
|
|
187
|
+
return processed
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Extended Config with RPCConfig
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { Procedures } from 'ts-procedures'
|
|
198
|
+
import { Type } from 'typebox'
|
|
199
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
200
|
+
|
|
201
|
+
type AppContext = { userId: string }
|
|
202
|
+
|
|
203
|
+
const { Create } = Procedures<AppContext, RPCConfig>()
|
|
204
|
+
|
|
205
|
+
// Every procedure now MUST include scope and version
|
|
206
|
+
const { GetUser } = Create(
|
|
207
|
+
'GetUser',
|
|
208
|
+
{
|
|
209
|
+
scope: 'users', // URL path segment
|
|
210
|
+
version: 1, // API version
|
|
211
|
+
description: 'Fetch user by ID',
|
|
212
|
+
schema: {
|
|
213
|
+
params: Type.Object({ userId: Type.String() }),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
async (ctx, params) => {
|
|
217
|
+
return await fetchUser(params.userId)
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
// Route: POST /users/get-user/1
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Custom Extended Config
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
interface AppConfig extends RPCConfig {
|
|
229
|
+
permissions?: string[]
|
|
230
|
+
deprecated?: boolean
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { Create, getProcedures } = Procedures<AppContext, AppConfig>()
|
|
234
|
+
|
|
235
|
+
const { AdminAction } = Create(
|
|
236
|
+
'AdminAction',
|
|
237
|
+
{
|
|
238
|
+
scope: 'admin',
|
|
239
|
+
version: 1,
|
|
240
|
+
permissions: ['admin:write'],
|
|
241
|
+
description: 'Perform admin action',
|
|
242
|
+
},
|
|
243
|
+
async (ctx, params) => { /* ... */ }
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
// Use getProcedures() for introspection
|
|
247
|
+
for (const proc of getProcedures()) {
|
|
248
|
+
console.log(proc.name, proc.config.permissions)
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Express RPC Integration
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import { Procedures } from 'ts-procedures'
|
|
258
|
+
import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
|
|
259
|
+
import { Type } from 'typebox'
|
|
260
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
261
|
+
|
|
262
|
+
// 1. Define context and procedures
|
|
263
|
+
type AppContext = { userId: string; requestId: string }
|
|
264
|
+
const { Create } = Procedures<AppContext, RPCConfig>()
|
|
265
|
+
|
|
266
|
+
const { GetUser } = Create(
|
|
267
|
+
'GetUser',
|
|
268
|
+
{ scope: 'users', version: 1, schema: { params: Type.Object({ id: Type.String() }) } },
|
|
269
|
+
async (ctx, params) => fetchUser(params.id)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const { UpdateUser } = Create(
|
|
273
|
+
'UpdateUser',
|
|
274
|
+
{ scope: 'users', version: 1 },
|
|
275
|
+
async (ctx, params) => updateUser(params)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
// 2. Build Express app
|
|
279
|
+
const app = new ExpressRPCAppBuilder({
|
|
280
|
+
pathPrefix: '/api',
|
|
281
|
+
onError: (procedure, req, res, error) => {
|
|
282
|
+
if (error instanceof ProcedureValidationError) {
|
|
283
|
+
res.status(400).json({ error: error.message, details: error.errors })
|
|
284
|
+
} else {
|
|
285
|
+
res.status(500).json({ error: error.message })
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
.register(
|
|
290
|
+
{ getProcedures: () => [GetUser.info, UpdateUser.info] }, // Or use the factory directly
|
|
291
|
+
async (req) => ({
|
|
292
|
+
userId: await getUserIdFromToken(req.headers.authorization),
|
|
293
|
+
requestId: req.headers['x-request-id'] || crypto.randomUUID(),
|
|
294
|
+
})
|
|
295
|
+
)
|
|
296
|
+
.build()
|
|
297
|
+
|
|
298
|
+
// Routes created:
|
|
299
|
+
// POST /api/users/get-user/1
|
|
300
|
+
// POST /api/users/update-user/1
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Hono RPC Integration
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
import { Procedures } from 'ts-procedures'
|
|
309
|
+
import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
|
|
310
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
311
|
+
|
|
312
|
+
type AppContext = { userId: string }
|
|
313
|
+
const RPC = Procedures<AppContext, RPCConfig>()
|
|
314
|
+
|
|
315
|
+
RPC.Create('GetUser', { scope: 'users', version: 1 }, async (ctx, params) => {
|
|
316
|
+
return fetchUser(params.id)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const app = new HonoRPCAppBuilder({ pathPrefix: '/api' })
|
|
320
|
+
.register(RPC, (c) => ({
|
|
321
|
+
userId: c.req.header('x-user-id') || 'anonymous',
|
|
322
|
+
}))
|
|
323
|
+
.build()
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Hono Streaming Integration
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
import { Procedures } from 'ts-procedures'
|
|
332
|
+
import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
|
|
333
|
+
import { Type } from 'typebox'
|
|
334
|
+
import type { RPCConfig } from 'ts-procedures/http'
|
|
335
|
+
|
|
336
|
+
type StreamContext = { userId: string }
|
|
337
|
+
const StreamRPC = Procedures<StreamContext, RPCConfig>()
|
|
338
|
+
|
|
339
|
+
const { StreamEvents } = StreamRPC.CreateStream(
|
|
340
|
+
'StreamEvents',
|
|
341
|
+
{
|
|
342
|
+
scope: 'events',
|
|
343
|
+
version: 1,
|
|
344
|
+
schema: {
|
|
345
|
+
params: Type.Object({ channel: Type.String() }),
|
|
346
|
+
yieldType: Type.Object({ type: Type.String(), payload: Type.Any() }),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
async function* (ctx, params) {
|
|
350
|
+
const sub = subscribe(params.channel, { signal: ctx.signal })
|
|
351
|
+
for await (const event of sub) {
|
|
352
|
+
// Attach SSE metadata
|
|
353
|
+
yield sse(event, { event: event.type, id: event.id })
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
const app = new HonoStreamAppBuilder({
|
|
359
|
+
pathPrefix: '/api',
|
|
360
|
+
defaultStreamMode: 'sse',
|
|
361
|
+
onMidStreamError: (procedure, c, error) => ({
|
|
362
|
+
data: { error: error.message },
|
|
363
|
+
closeStream: true,
|
|
364
|
+
}),
|
|
365
|
+
})
|
|
366
|
+
.register(StreamRPC, (c) => ({
|
|
367
|
+
userId: c.req.header('x-user-id') || 'anonymous',
|
|
368
|
+
}))
|
|
369
|
+
.build()
|
|
370
|
+
|
|
371
|
+
// Routes: GET|POST /api/events/stream-events/1
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Text Streaming Mode
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
const app = new HonoStreamAppBuilder({
|
|
380
|
+
defaultStreamMode: 'text', // Newline-delimited JSON
|
|
381
|
+
})
|
|
382
|
+
.register(StreamRPC, contextFactory)
|
|
383
|
+
.build()
|
|
384
|
+
|
|
385
|
+
// Each yield becomes: {"type":"event","payload":{...}}\n
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Hono API Integration (REST-style)
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import { Procedures } from 'ts-procedures'
|
|
394
|
+
import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
|
|
395
|
+
import type { APIConfig } from 'ts-procedures/http'
|
|
396
|
+
import { Type } from 'typebox'
|
|
397
|
+
|
|
398
|
+
type AppContext = { userId: string }
|
|
399
|
+
const API = Procedures<AppContext, APIConfig>()
|
|
400
|
+
|
|
401
|
+
API.Create('GetUser', {
|
|
402
|
+
path: '/users/:id',
|
|
403
|
+
method: 'get',
|
|
404
|
+
schema: {
|
|
405
|
+
input: {
|
|
406
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
407
|
+
},
|
|
408
|
+
returnType: Type.Object({ id: Type.String(), name: Type.String() }),
|
|
409
|
+
},
|
|
410
|
+
}, async (ctx, { pathParams }) => {
|
|
411
|
+
return await fetchUser(pathParams.id)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
API.Create('CreateUser', {
|
|
415
|
+
path: '/users',
|
|
416
|
+
method: 'post',
|
|
417
|
+
schema: {
|
|
418
|
+
input: {
|
|
419
|
+
body: Type.Object({ name: Type.String(), email: Type.String() }),
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
}, async (ctx, { body }) => {
|
|
423
|
+
return await createUser(body)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
API.Create('DeleteUser', {
|
|
427
|
+
path: '/users/:id',
|
|
428
|
+
method: 'delete',
|
|
429
|
+
schema: {
|
|
430
|
+
input: {
|
|
431
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
}, async (ctx, { pathParams }) => {
|
|
435
|
+
await deleteUser(pathParams.id)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const app = await new HonoAPIAppBuilder({
|
|
439
|
+
pathPrefix: '/api',
|
|
440
|
+
onError: (procedure, c, error) => {
|
|
441
|
+
if (error instanceof ProcedureValidationError) {
|
|
442
|
+
return c.json({ error: error.message, details: error.errors }, 400)
|
|
443
|
+
}
|
|
444
|
+
return c.json({ error: error.message }, 500)
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
.register(API, (c) => ({
|
|
448
|
+
userId: c.req.header('x-user-id') || 'anonymous',
|
|
449
|
+
}))
|
|
450
|
+
.build()
|
|
451
|
+
|
|
452
|
+
// Routes:
|
|
453
|
+
// GET /api/users/:id → 200
|
|
454
|
+
// POST /api/users → 201
|
|
455
|
+
// DELETE /api/users/:id → 204
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Extended Config with APIConfig
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
import { Procedures } from 'ts-procedures'
|
|
464
|
+
import type { APIConfig } from 'ts-procedures/http'
|
|
465
|
+
|
|
466
|
+
type AppContext = { userId: string }
|
|
467
|
+
|
|
468
|
+
const { Create } = Procedures<AppContext, APIConfig>()
|
|
469
|
+
|
|
470
|
+
// Every procedure now MUST include path and method
|
|
471
|
+
const { GetUser } = Create(
|
|
472
|
+
'GetUser',
|
|
473
|
+
{
|
|
474
|
+
path: '/users/:id',
|
|
475
|
+
method: 'get',
|
|
476
|
+
schema: {
|
|
477
|
+
input: {
|
|
478
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
479
|
+
query: Type.Object({ include: Type.Optional(Type.String()) }),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
async (ctx, { pathParams, query }) => {
|
|
484
|
+
return await fetchUser(pathParams.id, { include: query.include })
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## schema.input — Multi-Channel Structured Input
|
|
492
|
+
|
|
493
|
+
Use `schema.input` instead of `schema.params` for structured per-channel validation:
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
const { Create } = Procedures<AppContext, APIConfig>()
|
|
497
|
+
|
|
498
|
+
Create('UpdateUserField', {
|
|
499
|
+
path: '/users/:id',
|
|
500
|
+
method: 'put',
|
|
501
|
+
schema: {
|
|
502
|
+
input: {
|
|
503
|
+
pathParams: Type.Object({ id: Type.String() }),
|
|
504
|
+
query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
|
|
505
|
+
body: Type.Object({ field: Type.String(), value: Type.String() }),
|
|
506
|
+
headers: Type.Object({ 'x-idempotency-key': Type.String() }),
|
|
507
|
+
},
|
|
508
|
+
returnType: Type.Object({ ok: Type.Boolean() }),
|
|
509
|
+
},
|
|
510
|
+
}, async (ctx, { pathParams, query, body, headers }) => {
|
|
511
|
+
// Each channel independently typed and validated
|
|
512
|
+
// Validation errors include channel name: "Validation error for UpdateUserField in input.body"
|
|
513
|
+
await updateField(pathParams.id, body.field, body.value, {
|
|
514
|
+
idempotencyKey: headers['x-idempotency-key'],
|
|
515
|
+
})
|
|
516
|
+
if (query.notify) await notifyUser(pathParams.id)
|
|
517
|
+
return { ok: true }
|
|
518
|
+
})
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## APIInput Channel Constraint
|
|
524
|
+
|
|
525
|
+
Use `satisfies APIInput` to catch channel name typos at compile time:
|
|
526
|
+
|
|
527
|
+
```typescript
|
|
528
|
+
import type { APIInput } from 'ts-procedures/hono-api'
|
|
529
|
+
|
|
530
|
+
Create('Search', {
|
|
531
|
+
path: '/search',
|
|
532
|
+
method: 'get',
|
|
533
|
+
schema: {
|
|
534
|
+
input: {
|
|
535
|
+
query: Type.Object({ q: Type.String() }),
|
|
536
|
+
} satisfies APIInput,
|
|
537
|
+
},
|
|
538
|
+
}, async (ctx, { query }) => {
|
|
539
|
+
return await search(query.q)
|
|
540
|
+
})
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## onCreate Callback for Framework Integration
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
const registeredProcedures: TProcedureRegistration[] = []
|
|
549
|
+
|
|
550
|
+
const { Create } = Procedures<AppContext>({
|
|
551
|
+
onCreate: (procedure) => {
|
|
552
|
+
registeredProcedures.push(procedure)
|
|
553
|
+
console.log(`Registered: ${procedure.name}`)
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// Use for:
|
|
558
|
+
// - Route registration in custom frameworks
|
|
559
|
+
// - OpenAPI spec generation
|
|
560
|
+
// - Logging/monitoring setup
|
|
561
|
+
// - Permission registration
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
## getProcedures() for Introspection
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
const { Create, getProcedures } = Procedures<AppContext, RPCConfig>()
|
|
570
|
+
|
|
571
|
+
Create('GetUser', { scope: 'users', version: 1 }, async (ctx) => ({}))
|
|
572
|
+
Create('ListUsers', { scope: 'users', version: 1 }, async (ctx) => [])
|
|
573
|
+
|
|
574
|
+
// Generate documentation
|
|
575
|
+
const docs = getProcedures().map(proc => ({
|
|
576
|
+
name: proc.name,
|
|
577
|
+
path: `/${proc.config.scope}/${kebabCase(proc.name)}/${proc.config.version}`,
|
|
578
|
+
params: proc.config.schema?.params,
|
|
579
|
+
returnType: proc.config.schema?.returnType,
|
|
580
|
+
}))
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Multiple Factories for Access Control
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
type PublicContext = { requestId: string }
|
|
589
|
+
type AuthContext = { userId: string; requestId: string }
|
|
590
|
+
|
|
591
|
+
const PublicRPC = Procedures<PublicContext, RPCConfig>()
|
|
592
|
+
const AuthRPC = Procedures<AuthContext, RPCConfig>()
|
|
593
|
+
|
|
594
|
+
PublicRPC.Create('HealthCheck', { scope: 'health', version: 1 }, async () => ({ status: 'ok' }))
|
|
595
|
+
AuthRPC.Create('GetProfile', { scope: 'users', version: 1 }, async (ctx) => fetchProfile(ctx.userId))
|
|
596
|
+
|
|
597
|
+
// Register with different context resolvers
|
|
598
|
+
const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
|
|
599
|
+
.register(PublicRPC, (req) => ({ requestId: req.headers['x-request-id'] }))
|
|
600
|
+
.register(AuthRPC, async (req) => ({
|
|
601
|
+
userId: await authenticate(req),
|
|
602
|
+
requestId: req.headers['x-request-id'],
|
|
603
|
+
}))
|
|
604
|
+
.build()
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Documentation Generation with extendProcedureDoc
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
|
|
613
|
+
.register(RPC, contextFactory, ({ base, procedure }) => ({
|
|
614
|
+
summary: procedure.config.description,
|
|
615
|
+
tags: [base.scope],
|
|
616
|
+
security: procedure.config.permissions
|
|
617
|
+
? [{ bearerAuth: [] }]
|
|
618
|
+
: [],
|
|
619
|
+
}))
|
|
620
|
+
.build()
|
|
621
|
+
|
|
622
|
+
// Access generated docs
|
|
623
|
+
const openApiPaths = app.docs.map(doc => ({
|
|
624
|
+
[doc.path]: {
|
|
625
|
+
[doc.method]: {
|
|
626
|
+
summary: doc.summary,
|
|
627
|
+
tags: doc.tags,
|
|
628
|
+
requestBody: doc.jsonSchema.body
|
|
629
|
+
? { content: { 'application/json': { schema: doc.jsonSchema.body } } }
|
|
630
|
+
: undefined,
|
|
631
|
+
responses: {
|
|
632
|
+
200: doc.jsonSchema.response
|
|
633
|
+
? { content: { 'application/json': { schema: doc.jsonSchema.response } } }
|
|
634
|
+
: { description: 'Success' },
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
}))
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
## Testing Procedures
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
import { describe, test, expect } from 'vitest'
|
|
647
|
+
|
|
648
|
+
describe('GetUser', () => {
|
|
649
|
+
test('returns user for valid params', async () => {
|
|
650
|
+
const result = await GetUser(
|
|
651
|
+
{ userId: 'caller-1', requestId: 'test' },
|
|
652
|
+
{ id: 'user-123' }
|
|
653
|
+
)
|
|
654
|
+
expect(result).toEqual({ id: 'user-123', name: 'John' })
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test('throws ProcedureValidationError for invalid params', async () => {
|
|
658
|
+
await expect(
|
|
659
|
+
GetUser({ userId: 'caller-1', requestId: 'test' }, {})
|
|
660
|
+
).rejects.toThrow(ProcedureValidationError)
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
test('throws ProcedureError for business logic errors', async () => {
|
|
664
|
+
await expect(
|
|
665
|
+
GetUser({ userId: 'caller-1', requestId: 'test' }, { id: 'not-found' })
|
|
666
|
+
).rejects.toThrow(ProcedureError)
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## Testing Stream Procedures
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
describe('StreamNotifications', () => {
|
|
677
|
+
test('yields notifications', async () => {
|
|
678
|
+
const values = []
|
|
679
|
+
for await (const val of StreamNotifications({ userId: 'u1' }, { userId: 'u1' })) {
|
|
680
|
+
values.push(val)
|
|
681
|
+
if (values.length >= 3) break
|
|
682
|
+
}
|
|
683
|
+
expect(values).toHaveLength(3)
|
|
684
|
+
})
|
|
685
|
+
})
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
## Testing HTTP Builders
|
|
691
|
+
|
|
692
|
+
```typescript
|
|
693
|
+
import supertest from 'supertest'
|
|
694
|
+
|
|
695
|
+
describe('Express RPC', () => {
|
|
696
|
+
const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
|
|
697
|
+
.register(RPC, { userId: 'test-user', requestId: 'test' })
|
|
698
|
+
.build()
|
|
699
|
+
|
|
700
|
+
test('POST /api/users/get-user/1', async () => {
|
|
701
|
+
const res = await supertest(app)
|
|
702
|
+
.post('/api/users/get-user/1')
|
|
703
|
+
.send({ id: 'user-123' })
|
|
704
|
+
.expect(200)
|
|
705
|
+
|
|
706
|
+
expect(res.body).toEqual({ id: 'user-123', name: 'John' })
|
|
707
|
+
})
|
|
708
|
+
})
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## Lifecycle Hook Execution Order
|
|
714
|
+
|
|
715
|
+
### Standard RPC (Express/Hono)
|
|
716
|
+
```
|
|
717
|
+
onRequestStart → factoryContext() → handler() → onSuccess → onRequestEnd
|
|
718
|
+
→ onError → onRequestEnd
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Streaming (HonoStreamAppBuilder)
|
|
722
|
+
```
|
|
723
|
+
onRequestStart → factoryContext() → params validation
|
|
724
|
+
→ onPreStreamError (if invalid) → onRequestEnd
|
|
725
|
+
→ onStreamStart → handler yields → onStreamEnd → onRequestEnd
|
|
726
|
+
→ onMidStreamError (if throw) → onStreamEnd → onRequestEnd
|
|
727
|
+
```
|